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
+2
View File
@@ -260,6 +260,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema.
**App Settings modal** (`#appSettingsModal`): a left rail that is a **table of contents over ONE scrolling document**, not a tab switcher. Every section stays mounted (`.set-section`, ids `settings-terminal|appearance|layout|models|clis|notifications|voice|shortcuts|system`), the rail follows the scroll, and `switchSettingsTab(id)` keeps its historical name but SCROLLS instead of hiding. Phones swap the rail for the sticky `#appSettingsJump` pill (compact layout at ≤860px in mobile.css). ⚠️ **The load/save contract is `getElementById` by id**: `openAppSettings()`/`saveAppSettings()` read every control by a fixed id, so moving a control between sections is free but renaming or dropping one silently stops it loading or saving. `test/app-settings-structure.test.ts` is the static guard. ⚠️ Model cards (`#appSettingsModelCards`) and the effort segment are **views over hidden `<select>`s** that remain the source of truth; the cards hold the BASE model and the "1M context window" switch composes `base + [1m]` back into `claudeModel`, which is what retires the old "takes precedence over the toggle below" trap. ⚠️ `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` are still used by `#sessionOptionsModal` and `#createCaseModal`; the settings rail uses its own `set-*` classes and must not restyle them. ⚠️ `admin-ui.js` injects the multi-user Users entry into `.set-rail-items` + `.set-doc`, so those hooks must survive any restructure.
**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron)
**Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package)
+34 -19
View File
@@ -110,34 +110,49 @@
}
// ── Admin Users panel (injected into the App Settings modal) ──────────────
// The settings modal is a rail (table of contents) over ONE scrolling
// document, so this appends a rail entry plus a real section rather than a
// tab button plus a hidden panel.
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-tab="settings-users"]')) return;
const tabs = modal.querySelector('.modal-tabs');
const body = modal.querySelector('.modal-body');
if (!tabs || !body) return;
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
const rail = modal.querySelector('.set-rail-items');
const body = modal.querySelector('.set-doc');
if (!rail || !body) return;
const btn = document.createElement('button');
btn.className = 'modal-tab-btn';
btn.dataset.tab = 'settings-users';
btn.textContent = 'Users';
tabs.appendChild(btn);
const content = document.createElement('div');
content.className = 'modal-tab-content hidden';
btn.type = 'button';
btn.className = 'set-rail-item';
btn.dataset.section = 'settings-users';
btn.innerHTML =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
rail.appendChild(btn);
const content = document.createElement('section');
content.className = 'set-section';
content.id = 'settings-users';
content.dataset.label = 'Users';
content.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<span>
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
</span>
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
<h2>Users</h2>
</div>
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div class="set-group">
<div class="set-group-head"><h4>Accounts</h4></div>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
<div class="set-row-actions">
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
</div>
</div>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
</div>
</div>`;
body.appendChild(content);
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
+650 -591
View File
File diff suppressed because it is too large Load Diff
+356
View File
@@ -3099,3 +3099,359 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
padding: 0.65rem 1rem;
}
}
/* ============================================================================
App Settings, compact layout (<= 860px)
Same single scrolling document as the desktop rail layout; only the
navigation changes. The rail collapses to its search field and #appSettingsJump
takes over as the sticky "where am I / jump elsewhere" control, so a phone
spends its vertical budget on settings instead of on chrome.
Groups render as one inset rounded list with hairline dividers rather than a
stack of separate cards: at 390px the per-card borders were most of the pixels.
============================================================================ */
@media (max-width: 860px) {
#appSettingsModal .modal-content.modal-lg {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
}
#appSettingsModal .set-body {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Rail keeps only its search field, laid out as a bar */
#appSettingsModal .set-rail {
flex-direction: row;
align-items: center;
border-right: 0;
border-bottom: 1px solid var(--border);
background: transparent;
padding: 10px 14px;
overflow: visible;
flex-shrink: 0;
}
#appSettingsModal .set-rail-items,
#appSettingsModal .set-rail-foot {
display: none;
}
#appSettingsModal .set-search {
margin: 0;
flex: 1;
}
#appSettingsModal .set-search input {
padding: 9px 10px 9px 30px;
border-radius: 10px;
}
/* Save moves into the header; the bottom action bar would cost 60px */
#appSettingsModal .set-head-save {
display: inline-flex;
}
#appSettingsModal .set-foot {
display: none;
}
#appSettingsModal .set-doc {
padding: 0 14px 34px;
flex: 1;
}
/* ── jump control ──────────────────────────────────────────────────── */
#appSettingsModal .set-jump {
display: flex;
position: sticky;
top: 0;
z-index: 4;
align-items: center;
gap: 8px;
width: 100%;
margin: 10px 0 2px;
padding: 10px 12px;
border-radius: 11px;
font: inherit;
font-size: 0.82rem;
color: var(--text);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.3);
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
cursor: pointer;
}
#appSettingsModal .set-jump-ico {
color: var(--accent);
flex-shrink: 0;
}
#appSettingsModal .set-jump-label {
font-weight: 580;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#appSettingsModal .set-jump-chev {
margin-left: auto;
color: var(--text-muted);
flex-shrink: 0;
transition: transform 0.18s;
}
#appSettingsModal .set-jump[aria-expanded='true'] .set-jump-chev {
transform: rotate(180deg);
}
#appSettingsModal .set-jump-veil {
display: none;
position: fixed;
inset: 0;
z-index: 8;
background: rgba(4, 8, 13, 0.62);
}
#appSettingsModal .set-jump-menu {
display: none;
position: absolute;
left: 14px;
right: 14px;
z-index: 9;
padding: 7px;
border-radius: 16px;
/* Opaque on purpose: --floating-bg is translucent and the settings rows
behind the menu bleed through it. */
background: var(--bg-card);
border: 1px solid var(--control-border);
box-shadow: var(--elevated-shadow);
max-height: 70vh;
overflow-y: auto;
}
#appSettingsModal.jump-open .set-jump-veil,
#appSettingsModal.jump-open .set-jump-menu {
display: block;
}
#appSettingsModal .set-jump-row {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 11px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.82rem;
text-align: left;
}
#appSettingsModal .set-jump-row svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-jump-row .set-jump-count {
margin-left: auto;
font-size: 0.62rem;
color: var(--text-muted);
}
#appSettingsModal .set-jump-row.active {
background: rgba(var(--accent-rgb), 0.14);
color: var(--text);
font-weight: 570;
}
#appSettingsModal .set-jump-row.active svg {
color: var(--accent);
}
/* ── sections step down: the jump pill already names the current one ── */
#appSettingsModal .set-section {
padding-top: 0;
}
#appSettingsModal .set-section + .set-section {
border-top: 0;
margin-top: 0;
}
#appSettingsModal .set-section-head {
gap: 7px;
margin: 18px 0 2px;
}
#appSettingsModal .set-section-head svg {
padding: 0;
border: 0;
background: none;
color: var(--text-muted);
width: 12px;
height: 12px;
}
#appSettingsModal .set-section-head h2 {
font-size: 0.6rem;
font-weight: 640;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-section-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
}
#appSettingsModal .set-section-blurb {
display: none;
}
/* ── inset grouped list ────────────────────────────────────────────── */
#appSettingsModal .set-group {
margin-top: 14px;
}
#appSettingsModal .set-group + .set-group {
margin-top: 16px;
}
#appSettingsModal .set-group-head {
margin-bottom: 7px;
padding: 0 3px;
}
#appSettingsModal .set-group-hint {
padding: 0 3px;
}
#appSettingsModal .set-group-body {
gap: 0;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 13px;
overflow: hidden;
}
#appSettingsModal .set-group-body > .set-row {
background: transparent;
border: 0;
border-radius: 0;
padding: 12px 13px;
gap: 12px;
}
#appSettingsModal .set-group-body > .set-row + .set-row {
border-top: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-group-body > .set-chips,
#appSettingsModal .set-group-body > .set-modelgrid,
#appSettingsModal .set-group-body > .set-minigrid,
#appSettingsModal .set-group-body > #appSettingsShortcutsList {
padding: 12px;
}
#appSettingsModal .set-group-body > .event-type-grid {
padding: 12px;
margin: 0;
}
#appSettingsModal .set-row-label {
font-size: 0.84rem;
}
#appSettingsModal .set-row-desc {
font-size: 0.69rem;
max-width: none;
}
/* Fields go full width under their label instead of fighting for the row */
#appSettingsModal .set-row.has-field {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row.has-field .set-select,
#appSettingsModal .set-row.has-field .set-input {
width: 100%;
min-width: 0;
max-width: none;
box-sizing: border-box;
}
#appSettingsModal .set-row-actions-wide {
width: 100%;
}
#appSettingsModal .set-row-actions-wide .set-input {
flex: 1;
min-width: 0;
}
#appSettingsModal .set-num {
width: 76px;
}
/* Bigger touch targets for the toggles and chips */
#appSettingsModal .switch-sm {
width: 40px;
height: 24px;
}
#appSettingsModal .switch-sm .slider:before {
height: 18px;
width: 18px;
}
#appSettingsModal .switch-sm input:checked + .slider:before {
transform: translateX(16px);
}
#appSettingsModal .set-chip {
font-size: 0.78rem;
padding: 9px 14px;
}
#appSettingsModal .set-modelgrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
#appSettingsModal .set-minigrid {
grid-template-columns: 1fr;
}
#appSettingsModal .set-mini .set-select {
width: 148px;
}
/* One scrollable line beats a ragged two-row wrap for 7 effort levels */
#appSettingsModal .set-segment {
overflow-x: auto;
scrollbar-width: none;
}
#appSettingsModal .set-segment::-webkit-scrollbar {
display: none;
}
#appSettingsModal .set-segment button {
flex: 0 0 auto;
padding: 8px 12px;
}
}
+348 -33
View File
@@ -354,11 +354,8 @@ Object.assign(CodemanApp.prototype, {
// Phone overview home screen: only meaningful under 430px, so the row is
// hidden elsewhere rather than offering a toggle that changes nothing.
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
const phoneOnly = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem');
if (mobileOverviewItem) mobileOverviewItem.style.display = phoneOnly;
const phoneSection = document.getElementById('appSettingsPhoneSection');
if (phoneSection) phoneSection.style.display = phoneOnly;
if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none';
// Session Manager, Away Digest and Cron buttons all default OFF (opt-in under
// Display → Header Displays; the Cron button also ships with btn-cron--hidden
// in the template, so an unchecked box and a hidden button stay consistent).
@@ -500,12 +497,15 @@ Object.assign(CodemanApp.prototype, {
// Updates section — show current version, reset transient result/progress UI.
this._initUpdatesSection();
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
// Model cards + effort segment are views over the hidden <select>s above,
// so they must be synced AFTER those have been given their stored values.
this._initSettingsNav();
this._syncSettingsChips();
this._syncModelCards();
this._syncEffortSegment();
// Back to the top of the document (one scroll, not a tab reset).
this.switchSettingsTab('settings-terminal');
const modal = document.getElementById('appSettingsModal');
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.onclick = () => this.switchSettingsTab(btn.dataset.tab);
});
modal.classList.add('active');
// Activate focus trap
@@ -514,44 +514,359 @@ Object.assign(CodemanApp.prototype, {
},
/**
* Show the App Settings "Codex CLI" tab only on instances where the codex
* binary actually resolves. Both settings on it (approval bypass, animated
* status effects) are passed to `codex` at launch, so on a box without codex
* the tab is a promise nothing can keep.
* Show the App Settings "Codex" group only on instances where the codex binary
* actually resolves. Both settings in it (approval bypass, animated status
* effects) are passed to `codex` at launch, so on a box without codex the
* group is a promise nothing can keep.
*
* Availability comes from the injected `window.__codemanCliAvailable`, shared
* with the welcome buttons and the run-mode dropdown, so the tab never flickers
* in and back out. Only the tab BUTTON is toggled: the panel already carries
* `.modal-tab-content.hidden` unless it is the selected tab, and
* openAppSettings() always reopens on Display, so an unreachable button is
* enough to keep the panel unreachable.
* with the welcome buttons and the run-mode dropdown, so the group never
* flickers in and back out. The inputs stay in the DOM either way, so a user
* without codex can never silently wipe the codex prefs of an instance that
* has it (openAppSettings/saveAppSettings still read and write them).
*
* Note the inverted default versus the run buttons: an UNKNOWN flag hides this
* tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
* and are still saved), whereas hiding a run button would leave a working
* install with nothing to click.
* group. Hiding it costs a user nothing, whereas hiding a run button would
* leave a working install with nothing to click.
*/
_applyCodexSettingsVisibility() {
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
const group = document.getElementById('appSettingsCodexGroup');
if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
},
switchSettingsTab(tabName) {
/**
* Scroll the settings document to a section.
*
* Kept under the historical `switchSettingsTab` name because it is the shared
* entry point: openAppSettings() calls it, and admin-ui.js's injected Users
* entry routes through it too. Sections are never hidden any more — the rail
* is a table of contents over ONE document, so "switching" is a scroll.
*/
switchSettingsTab(sectionId) {
// The Shortcuts list renders lazily so it reflects the CURRENT registry
// (defaults + overrides) every time it is reached.
if (sectionId === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const doc = document.getElementById('appSettingsDoc');
const section = document.getElementById(sectionId);
if (doc && section && typeof section.offsetTop === 'number') {
// On phones the jump pill is sticky at the top of the document, so land
// the section head below it instead of underneath it.
const jump = document.getElementById('appSettingsJump');
const inset = jump && jump.offsetParent ? jump.offsetHeight + 16 : 6;
doc.scrollTop = Math.max(0, section.offsetTop - inset);
}
this._setActiveSettingsSection(sectionId);
},
/** Paint the rail + jump pill for the section currently in view. */
_setActiveSettingsSection(sectionId) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
if (!modal || typeof modal.querySelectorAll !== 'function') return;
let active = null;
modal.querySelectorAll('.set-rail-item').forEach(item => {
const on = item.dataset.section === sectionId;
item.classList.toggle('active', on);
if (on) active = item;
});
// Toggle hidden class on tab content
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
modal.querySelectorAll('.set-jump-row').forEach(row => {
row.classList.toggle('active', row.dataset.section === sectionId);
});
// The Shortcuts tab renders lazily so the list reflects the CURRENT
// registry (defaults + overrides) every time it is opened.
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
const label = document.getElementById('appSettingsJump')?.querySelector('.set-jump-label');
if (label && active) label.textContent = active.textContent.trim();
const ico = document.getElementById('appSettingsJump')?.querySelector('.set-jump-ico');
const src = active?.querySelector('svg');
if (ico && src) ico.innerHTML = src.innerHTML;
},
/**
* Wire the settings navigation once per page: rail clicks, the phone jump
* menu, scroll-spy, live search, chip/card/segment views over the real inputs,
* and the collapsible Advanced group. Idempotent — openAppSettings() calls it
* on every open, and re-registering listeners on every open would multiply
* them across a long-lived tab.
*/
_initSettingsNav() {
const modal = document.getElementById('appSettingsModal');
const doc = document.getElementById('appSettingsDoc');
if (!modal || !doc || typeof modal.querySelectorAll !== 'function') return;
this._buildModelCards();
this._buildEffortSegment();
// Rebuilt on every open: admin-ui.js appends its Users entry to the rail
// after the first open, and the menu must not drift from the rail.
this._buildSettingsJumpMenu();
if (modal.dataset.navReady === '1') return;
modal.dataset.navReady = '1';
// Delegated so rail entries injected later (Users) work without rewiring.
modal.querySelector('.set-rail-items')?.addEventListener('click', e => {
const item = e.target.closest?.('.set-rail-item');
if (item?.dataset.section) this.switchSettingsTab(item.dataset.section);
});
document.getElementById('appSettingsJumpMenu')?.addEventListener('click', e => {
const row = e.target.closest?.('.set-jump-row');
if (!row?.dataset.section) return;
this._toggleSettingsJump(false);
this.switchSettingsTab(row.dataset.section);
});
document.getElementById('appSettingsJump')?.addEventListener('click', () => this._toggleSettingsJump());
document.getElementById('appSettingsJumpVeil')?.addEventListener('click', () => this._toggleSettingsJump(false));
// Scroll-spy: the rail follows the document rather than driving it.
doc.addEventListener('scroll', () => {
if (this._settingsSpyQueued) return;
this._settingsSpyQueued = true;
requestAnimationFrame(() => {
this._settingsSpyQueued = false;
const sections = [...doc.querySelectorAll('.set-section')].filter(s => s.offsetParent !== null);
if (!sections.length) return;
let current = sections[0].id;
for (const s of sections) {
if (s.offsetTop - doc.scrollTop <= 140) current = s.id;
}
this._setActiveSettingsSection(current);
});
});
const search = document.getElementById('appSettingsSearch');
search?.addEventListener('input', () => this._filterSettings(search.value));
// Chips are labels wrapping the real checkbox; mirror the checked state onto
// the label so the styling does not depend on :has() support.
modal.querySelectorAll('.set-chip input').forEach(input => {
input.addEventListener('change', () => this._syncSettingsChips());
});
const advHead = modal.querySelector('.set-group-head-toggle');
const advGroup = advHead?.closest('.set-group-advanced');
if (advHead && advGroup) {
const toggle = () => {
const open = advGroup.classList.toggle('open');
advHead.setAttribute('aria-expanded', open ? 'true' : 'false');
};
advHead.addEventListener('click', toggle);
advHead.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
document.getElementById('appSettingsOpusContext1m')?.addEventListener('change', () => this._applyModelSelection());
},
/** Phone jump menu, mirrored from the rail so the two can never drift. */
_buildSettingsJumpMenu() {
const modal = document.getElementById('appSettingsModal');
const menu = document.getElementById('appSettingsJumpMenu');
if (!modal || !menu) return;
menu.innerHTML = '';
modal.querySelectorAll('.set-rail-item').forEach(item => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'set-jump-row';
row.dataset.section = item.dataset.section;
row.innerHTML = item.innerHTML;
const section = document.getElementById(item.dataset.section);
const count = section ? section.querySelectorAll('input, select').length : 0;
if (count) {
const n = document.createElement('span');
n.className = 'set-jump-count';
n.textContent = String(count);
row.appendChild(n);
}
menu.appendChild(row);
});
},
_toggleSettingsJump(force) {
const modal = document.getElementById('appSettingsModal');
if (!modal) return;
const open = force === undefined ? !modal.classList.contains('jump-open') : force;
modal.classList.toggle('jump-open', open);
document.getElementById('appSettingsJump')?.setAttribute('aria-expanded', open ? 'true' : 'false');
},
/** Mirror checkbox state onto the chip labels (see _initSettingsNav). */
_syncSettingsChips() {
document.querySelectorAll('#appSettingsModal .set-chip').forEach(chip => {
chip.classList.toggle('is-on', !!chip.querySelector('input')?.checked);
});
},
/**
* Build the model picker cards from the hidden <select>'s own options, so the
* select stays the single source of truth that openAppSettings/saveAppSettings
* read and write by id. The `[1m]` variants are folded away: context width is a
* property of the chosen model (the "1M context window" switch), not a rival
* setting that silently loses to it.
*/
_buildModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid || grid.dataset.built === '1' || !select.options) return;
grid.innerHTML = '';
[...select.options]
.filter(opt => opt.dataset.variant !== '1m')
.forEach(opt => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'set-modelcard';
card.setAttribute('role', 'radio');
card.dataset.value = opt.value;
if (opt.dataset.ctx === '1') card.dataset.ctx = '1';
const top = document.createElement('span');
top.className = 'set-mc-top';
const name = document.createElement('span');
name.className = 'set-mc-name';
name.textContent = opt.textContent;
top.appendChild(name);
const dot = document.createElement('span');
dot.className = 'set-mc-dot';
top.appendChild(dot);
card.appendChild(top);
const meta = document.createElement('span');
meta.className = 'set-mc-meta';
meta.textContent = opt.dataset.meta || '';
card.appendChild(meta);
if (opt.dataset.ctx === '1') {
const ctx = document.createElement('span');
ctx.className = 'set-mc-ctx';
ctx.textContent = '1M capable';
card.appendChild(ctx);
}
card.addEventListener('click', () => {
this._settingsModelBase = opt.value;
this._applyModelSelection();
});
grid.appendChild(card);
});
grid.dataset.built = '1';
},
/** Derive card + context-switch state from the select's stored value. */
_syncModelCards() {
const select = document.getElementById('appSettingsClaudeModel');
if (!select) return;
const value = select.value || '';
this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value;
if (value.endsWith('[1m]')) {
const ctx = document.getElementById('appSettingsOpusContext1m');
if (ctx) ctx.checked = true;
}
this._applyModelSelection();
},
/** Compose card + context switch back into the select's value. */
_applyModelSelection() {
const select = document.getElementById('appSettingsClaudeModel');
const grid = document.getElementById('appSettingsModelCards');
if (!select || !grid) return;
const base = this._settingsModelBase || '';
let capable = false;
grid.querySelectorAll('.set-modelcard').forEach(card => {
const on = card.dataset.value === base;
card.classList.toggle('selected', on);
card.setAttribute('aria-checked', on ? 'true' : 'false');
if (on) capable = card.dataset.ctx === '1';
});
const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked;
select.value = base && capable && ctxOn ? `${base}[1m]` : base;
// A model with no 1M variant makes the switch inert; say so instead of
// leaving a toggle that looks like it does something.
const row = document.getElementById('appSettingsContextRow');
const desc = document.getElementById('appSettingsContextDesc');
const inert = !!base && !capable;
row?.classList.toggle('set-row-disabled', inert);
if (desc) {
desc.textContent = inert
? 'The selected model has no 1M variant.'
: base
? 'Available for Fable 5, Opus and Opus 4.6.'
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
}
},
_buildEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg || seg.dataset.built === '1' || !select.options) return;
seg.innerHTML = '';
[...select.options].forEach(opt => {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('role', 'radio');
btn.dataset.value = opt.value;
btn.textContent = opt.textContent;
btn.addEventListener('click', () => {
select.value = opt.value;
this._syncEffortSegment();
});
seg.appendChild(btn);
});
seg.dataset.built = '1';
},
_syncEffortSegment() {
const select = document.getElementById('appSettingsThinkingEffort');
const seg = document.getElementById('appSettingsEffortSegment');
if (!select || !seg) return;
seg.querySelectorAll('button').forEach(btn => {
const on = btn.dataset.value === (select.value || '');
btn.classList.toggle('selected', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
},
/**
* Live filter across every section. Everything stays mounted (that is the
* point of the single-document layout), so a search only hides units that do
* not match, then collapses groups and sections left with nothing visible.
*/
_filterSettings(query) {
const doc = document.getElementById('appSettingsDoc');
if (!doc) return;
const q = (query || '').trim().toLowerCase();
const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList';
const units = [...doc.querySelectorAll(UNIT)];
let anyVisible = false;
units.forEach(unit => {
if (!q) {
unit.classList.remove('set-hit-hidden');
return;
}
const hay = `${unit.dataset?.search || ''} ${unit.textContent || ''}`.toLowerCase();
const hit = hay.includes(q);
unit.classList.toggle('set-hit-hidden', !hit);
if (hit) anyVisible = true;
});
// A chip wrapper is only empty when every chip inside it is hidden.
doc.querySelectorAll('.set-chips').forEach(wrap => {
const hasVisible = [...wrap.querySelectorAll('.set-chip')].some(c => !c.classList.contains('set-hit-hidden'));
wrap.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
doc.querySelectorAll('.set-group').forEach(group => {
const hasVisible = [...group.querySelectorAll(UNIT)].some(u => !u.classList.contains('set-hit-hidden'));
group.classList.toggle('set-hit-hidden', !!q && !hasVisible);
// An Advanced group that matches must open, or the hit stays invisible.
if (q && hasVisible) group.classList.add('open');
});
doc.querySelectorAll('.set-section').forEach(section => {
const hasVisible = [...section.querySelectorAll('.set-group')].some(g => !g.classList.contains('set-hit-hidden'));
section.classList.toggle('set-hit-hidden', !!q && !hasVisible);
});
const empty = document.getElementById('appSettingsSearchEmpty');
if (empty) empty.hidden = !q || anyVisible;
if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open'));
},
closeAppSettings() {
this._toggleSettingsJump(false);
document.getElementById('appSettingsModal').classList.remove('active');
// Deactivate focus trap and restore focus
+689 -5
View File
@@ -1321,6 +1321,7 @@ html[data-line-anim="packet"] .connection-line.line-enter {
/* Performance: GPU acceleration for animation */
will-change: opacity;
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
@@ -5765,11 +5766,6 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
min-height: 0;
}
/* App Settings packs a 2-column toggle grid; give it room so right-column
switches aren't cramped against the edge (other modal-lg modals stay 540). */
#appSettingsModal .modal-content.modal-lg {
max-width: 600px;
}
/* Mobile Case Picker - Base Styles */
.mobile-case-picker-sheet {
@@ -14308,3 +14304,691 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
animation: none !important;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
App Settings (#appSettingsModal)
A left rail that is a TABLE OF CONTENTS over ONE scrolling document, not a
tab switcher: every section stays mounted, the rail follows the scroll, and
the browser's own find-in-page still works across all of them.
Scoped under #appSettingsModal on purpose. `.modal-tabs`/`.modal-tab-btn`
are shared with #sessionOptionsModal and #createCaseModal, so this file
introduces its own `set-*` classes rather than restyling those.
═══════════════════════════════════════════════════════════════════════════ */
#appSettingsModal .modal-content.modal-lg {
max-width: min(960px, 96vw);
width: min(960px, 96vw);
height: min(700px, 88vh);
max-height: 88vh;
}
#appSettingsModal .set-shell-head {
flex-shrink: 0;
}
#appSettingsModal .set-head-actions {
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 6px;
margin-left: auto;
}
/* The phone layout drops the bottom action bar and saves from the header
instead; on desktop the footer owns Save, so this stays hidden. */
#appSettingsModal .set-head-save {
display: none;
font: inherit;
font-size: 0.82rem;
font-weight: 620;
letter-spacing: -0.01em;
padding: 8px 15px;
border: 0;
border-radius: 10px;
cursor: pointer;
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
}
#appSettingsModal .set-body {
display: grid;
grid-template-columns: 212px minmax(0, 1fr);
flex: 1;
min-height: 0;
}
#appSettingsModal .set-foot {
margin-top: 0;
flex-shrink: 0;
}
/* ── rail ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-rail {
display: flex;
flex-direction: column;
padding: 12px 10px;
border-right: 1px solid var(--border);
background: rgba(0, 0, 0, 0.16);
overflow-y: auto;
min-height: 0;
}
#appSettingsModal .set-search {
position: relative;
display: flex;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
}
#appSettingsModal .set-search svg {
position: absolute;
left: 9px;
color: var(--text-muted);
pointer-events: none;
}
#appSettingsModal .set-search input {
width: 100%;
box-sizing: border-box;
padding: 7px 9px 7px 28px;
font: inherit;
font-size: 0.75rem;
color: var(--text);
background: rgba(0, 0, 0, 0.22);
border: 1px solid var(--border);
border-radius: 8px;
outline: none;
-webkit-appearance: none;
appearance: none;
}
#appSettingsModal .set-search input:focus {
border-color: var(--accent);
}
#appSettingsModal .set-rail-items {
display: flex;
flex-direction: column;
gap: 1px;
}
#appSettingsModal .set-rail-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.77rem;
cursor: pointer;
text-align: left;
position: relative;
transition: color 0.15s, background 0.15s;
}
#appSettingsModal .set-rail-item svg {
color: var(--text-muted);
flex-shrink: 0;
}
#appSettingsModal .set-rail-item:hover {
background: var(--control-bg);
color: var(--text);
}
#appSettingsModal .set-rail-item.active {
color: var(--accent);
font-weight: 580;
}
#appSettingsModal .set-rail-item.active svg {
color: var(--accent);
}
#appSettingsModal .set-rail-item.active::before {
content: '';
position: absolute;
left: 2px;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 2px;
background: var(--accent);
}
#appSettingsModal .set-rail-foot {
margin: auto 0 0;
padding: 10px 10px 2px;
font-size: 0.62rem;
line-height: 1.5;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* ── document ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-doc {
overflow-y: auto;
padding: 0 22px 40px;
min-width: 0;
min-height: 0;
position: relative;
}
#appSettingsModal .set-section {
padding-top: 20px;
}
#appSettingsModal .set-section + .set-section {
border-top: 1px solid var(--border);
margin-top: 26px;
}
#appSettingsModal .set-section-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
#appSettingsModal .set-section-head svg {
color: var(--accent);
flex-shrink: 0;
padding: 5px;
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.24);
box-sizing: content-box;
}
#appSettingsModal .set-section-head h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 640;
letter-spacing: -0.015em;
color: var(--text);
}
#appSettingsModal .set-section-blurb {
margin: 0 0 14px 36px;
font-size: 0.69rem;
color: var(--text-muted);
}
#appSettingsModal .set-empty {
margin: 24px 0 0;
font-size: 0.78rem;
color: var(--text-muted);
text-align: center;
}
/* ── groups ───────────────────────────────────────────────────────────── */
#appSettingsModal .set-group + .set-group {
margin-top: 18px;
}
#appSettingsModal .set-group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#appSettingsModal .set-group-head h4 {
margin: 0;
font-size: 0.63rem;
font-weight: 640;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
#appSettingsModal .set-group-head::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
order: 9;
}
#appSettingsModal .set-group-head-toggle {
cursor: pointer;
}
#appSettingsModal .set-adv {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.62rem;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2px 8px;
}
#appSettingsModal .set-adv svg {
transition: transform 0.18s;
}
#appSettingsModal .set-group-advanced.open .set-adv svg {
transform: rotate(180deg);
}
#appSettingsModal .set-group-advanced:not(.open) .set-group-body,
#appSettingsModal .set-group-advanced:not(.open) .set-group-hint {
display: none;
}
#appSettingsModal .set-group-hint {
margin: -2px 0 9px;
font-size: 0.68rem;
line-height: 1.45;
color: var(--text-muted);
}
#appSettingsModal .set-group-body {
display: flex;
flex-direction: column;
gap: 5px;
}
/* ── rows ─────────────────────────────────────────────────────────────── */
#appSettingsModal .set-row {
display: flex;
align-items: center;
gap: 14px;
padding: 9px 12px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-row:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.09);
}
#appSettingsModal .set-row-block {
flex-direction: column;
align-items: stretch;
gap: 9px;
}
#appSettingsModal .set-row-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
#appSettingsModal .set-row-label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 0.79rem;
color: var(--text);
letter-spacing: -0.005em;
}
#appSettingsModal .set-row-desc {
font-size: 0.66rem;
line-height: 1.45;
color: var(--text-muted);
max-width: 62ch;
}
#appSettingsModal .set-row-actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
#appSettingsModal .set-scope,
#appSettingsModal .set-tag {
font-size: 0.52rem;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
padding: 1px 4px;
white-space: nowrap;
}
#appSettingsModal .set-scope {
color: var(--text-muted);
border: 1px solid var(--border);
opacity: 0.8;
}
#appSettingsModal .set-tag {
color: var(--accent);
background: rgba(var(--accent-rgb), 0.13);
border: 1px solid rgba(var(--accent-rgb), 0.25);
}
#appSettingsModal .set-tag-beta {
color: var(--yellow);
background: rgba(240, 194, 90, 0.12);
border-color: rgba(240, 194, 90, 0.28);
}
/* ── controls ─────────────────────────────────────────────────────────── */
#appSettingsModal .set-select,
#appSettingsModal .set-input,
#appSettingsModal .set-num {
font: inherit;
font-size: 0.74rem;
color: var(--text);
background: rgba(0, 0, 0, 0.24);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px 8px;
outline: none;
flex-shrink: 0;
}
#appSettingsModal .set-select:focus,
#appSettingsModal .set-input:focus,
#appSettingsModal .set-num:focus {
border-color: var(--accent);
}
#appSettingsModal .set-select {
min-width: 190px;
max-width: 260px;
}
#appSettingsModal .set-input {
width: 220px;
}
#appSettingsModal .set-num {
width: 62px;
text-align: right;
}
#appSettingsModal .set-unit {
font-size: 0.65rem;
color: var(--text-muted);
}
#appSettingsModal .set-static {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .mono {
font-family: var(--font-mono);
}
#appSettingsModal .set-copy {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
text-decoration: underline;
cursor: pointer;
word-break: break-all;
}
/* The cards and the segmented control are the visible UI; the underlying
<select> stays in the DOM as the single source of truth that
openAppSettings()/saveAppSettings() read and write by id. */
#appSettingsModal .set-field-hidden {
display: none !important;
}
#appSettingsModal .set-note,
#appSettingsModal .set-notes,
#appSettingsModal .set-progress {
font-size: 0.72rem;
line-height: 1.5;
color: var(--text-secondary);
padding: 8px 10px;
border-radius: 8px;
}
#appSettingsModal .set-notes {
max-height: 160px;
overflow: auto;
font-size: 0.7rem;
white-space: pre-wrap;
word-break: break-word;
background: rgba(127, 127, 127, 0.08);
border: 1px solid var(--border);
}
#appSettingsModal .set-progress {
border: 1px solid var(--border);
}
/* ── chips (header buttons, panels, features, levels) ─────────────────── */
#appSettingsModal .set-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#appSettingsModal .set-chip {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.72rem;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
color: var(--text-dim);
cursor: pointer;
user-select: none;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#appSettingsModal .set-chip input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
#appSettingsModal .set-chip::before {
content: '\2713';
font-size: 0.8em;
line-height: 1;
width: 0;
overflow: hidden;
opacity: 0;
transition: width 0.15s, opacity 0.15s;
}
#appSettingsModal .set-chip:hover {
border-color: var(--control-border-hover);
color: var(--text);
}
/* `.is-on` is written by settings-ui.js on load and on change; `:has()` keeps
the state correct for any chip toggled before that listener runs. */
#appSettingsModal .set-chip.is-on,
#appSettingsModal .set-chip:has(input:checked) {
color: var(--accent);
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.12);
}
#appSettingsModal .set-chip.is-on::before,
#appSettingsModal .set-chip:has(input:checked)::before {
width: 0.85em;
opacity: 1;
}
#appSettingsModal .set-chip:focus-within {
outline: 2px solid rgba(var(--accent-rgb), 0.5);
outline-offset: 1px;
}
/* ── model cards ──────────────────────────────────────────────────────── */
#appSettingsModal .set-modelgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
#appSettingsModal .set-modelcard {
display: flex;
flex-direction: column;
gap: 3px;
text-align: left;
font: inherit;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid var(--control-border);
background: rgba(255, 255, 255, 0.028);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
#appSettingsModal .set-modelcard:hover {
border-color: var(--control-border-hover);
background: rgba(255, 255, 255, 0.05);
}
#appSettingsModal .set-modelcard.selected {
border-color: rgba(var(--accent-rgb), 0.55);
background: rgba(var(--accent-rgb), 0.11);
box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.2);
}
#appSettingsModal .set-mc-top {
display: flex;
align-items: center;
gap: 6px;
}
#appSettingsModal .set-mc-name {
font-size: 0.78rem;
color: var(--text);
font-weight: 560;
}
#appSettingsModal .set-mc-dot {
margin-left: auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.8);
opacity: 0;
}
#appSettingsModal .set-modelcard.selected .set-mc-dot {
opacity: 1;
}
#appSettingsModal .set-mc-meta {
font-size: 0.64rem;
color: var(--text-muted);
}
#appSettingsModal .set-mc-ctx {
margin-top: 2px;
align-self: flex-start;
font-size: 0.53rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-dim);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 4px;
}
#appSettingsModal .set-row-disabled {
opacity: 0.45;
}
/* ── segmented control (thinking effort) ──────────────────────────────── */
#appSettingsModal .set-segment {
display: flex;
gap: 3px;
padding: 3px;
background: rgba(0, 0, 0, 0.26);
border: 1px solid var(--border);
border-radius: 9px;
}
#appSettingsModal .set-segment button {
flex: 1;
font: inherit;
font-size: 0.68rem;
padding: 6px 4px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
#appSettingsModal .set-segment button:hover {
color: var(--text);
background: var(--control-bg);
}
#appSettingsModal .set-segment button.selected {
background: linear-gradient(180deg, var(--accent-grad-a), var(--accent-grad-b));
color: var(--accent-ink);
font-weight: 620;
}
/* ── task-routing 2x2 ─────────────────────────────────────────────────── */
#appSettingsModal .set-minigrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
}
#appSettingsModal .set-mini {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
border: 1px solid rgba(255, 255, 255, 0.055);
}
#appSettingsModal .set-mini-label {
font-size: 0.74rem;
color: var(--text-dim);
}
#appSettingsModal .set-mini .set-select {
min-width: 0;
width: 128px;
}
/* Search hides non-matching units; everything stays mounted underneath. */
#appSettingsModal .set-hit-hidden {
display: none !important;
}
/* ── phone jump control: desktop hides it entirely ────────────────────── */
#appSettingsModal .set-jump,
#appSettingsModal .set-jump-menu,
#appSettingsModal .set-jump-veil {
display: none;
}
/* ── injected sections (admin Users panel) ────────────────────────────── */
#appSettingsModal .set-section .form-hint {
display: block;
margin: 0 0 10px;
}
+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);
});