Compare commits

..
Author SHA1 Message Date
Codeman maintainer 8fcfdb1e6e feat(tabs): orbit the working ring around a busy session tab's dot
The desktop home rail and the phone overview both draw a spinning
`tab-load-spin` ring around their green dot while a session works; the
tab strip itself only pulsed. Same ring on the tab dot now, so "working"
reads identically on every surface.

Drawn as a ::after border circle rather than a halo: the skin block sets
`box-shadow: none` on .tab-status.busy to keep tab dots quiet and
outranks any plain class rule, and a pseudo-element sidesteps that
without reintroducing the glow. It is absolutely positioned, so it never
widens the tab or shifts the label, and it is disabled under
prefers-reduced-motion.

Phones keep their existing tell (a 9px dot with a glow) and suppress the
ring: a 15px ring inside a 32px tab would sit on top of the tab name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 04:00:05 +02:00
Codeman maintainer 45ad9de89e 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>
2026-08-10 03:59:43 +02:00
14 changed files with 2435 additions and 1240 deletions
+2 -2
View File
@@ -252,8 +252,6 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
**Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-<backend>` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere).
**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview.
@@ -262,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)
+35 -20
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 id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></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(--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;
+8 -82
View File
@@ -3461,54 +3461,6 @@ class CodemanApp {
tab.classList.remove('active');
}
}
// #257: selection used to stop at the class toggle. On phones/tablets the
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
// Alt+N or a push notification could stay parked off-screen.
this._scrollActiveTabIntoView(sessionId);
}
/**
* Scroll the tab strip so the given (default: active) tab is visible.
*
* Only phones/tablets scroll the strip (desktop wraps to a second row), and
* the pure policy no-ops whenever there is nothing to scroll, so this is a
* cheap call on every device.
*
* Deliberately NOT scrollIntoView(): that also scrolls every scrollable
* ANCESTOR, which on a phone is the document itself. With the header fixed
* and the keyboard possibly open, a vertical nudge there shifts the whole
* app. Rect math + scrollLeft touches exactly one scroller.
*/
_scrollActiveTabIntoView(sessionId, behavior = 'smooth') {
const container = this.$('sessionTabs');
if (!container) return;
const tab =
(sessionId && container.querySelector(`.session-tab[data-id="${sessionId}"]`)) ||
container.querySelector('.session-tab.active');
if (!tab) return;
const policy = window.CodemanTabOverflow?.computeTabScrollLeft;
if (!policy) return;
const containerRect = container.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const target = policy({
scrollLeft: container.scrollLeft,
clientWidth: container.clientWidth,
scrollWidth: container.scrollWidth,
// Offsets are relative to the SCROLL CONTENT, not the offsetParent: the
// tabs' offsetParent is the positioned header, so offsetLeft would carry
// the brand column's width into the math.
tabLeft: tabRect.left - containerRect.left + container.scrollLeft,
tabWidth: tabRect.width,
});
if (Math.abs(target - container.scrollLeft) < 1) return;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
if (typeof container.scrollTo === 'function') {
container.scrollTo({ left: target, behavior: reduceMotion ? 'auto' : behavior });
} else {
container.scrollLeft = target;
}
}
_setTerminalLoadState(sessionId, selectGen, phase) {
@@ -3726,11 +3678,6 @@ class CodemanApp {
this._fullRenderSessionTabs();
}
// Keep the reveal-on-change bookkeeping honest when only the incremental
// branch ran: _updateActiveTabImmediate has already scrolled the new active
// tab into view, so the next full rebuild must not treat it as a change.
this._lastRenderedActiveTabId = this.activeSessionId;
this.updateTabOverflowMode();
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
// so measuring mid-animation would decide the wrap on collapsed widths.
@@ -3802,25 +3749,15 @@ class CodemanApp {
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
this.cancelHideSubagentDropdown();
// #257: replacing innerHTML below resets scrollLeft to 0. On phones the
// strip scrolls, and ambient rebuilds (a task badge appearing, a session
// created elsewhere) fire often enough that a user swiping toward the
// right-hand tabs kept getting yanked back to the first one. Remember
// where the strip was; the browser clamps the restore to the new content.
const prevScrollLeft = container.scrollLeft;
const prevActiveTabId = this._lastRenderedActiveTabId;
const isFirstRender = !container.querySelector('.session-tab');
// Build tabs HTML using array for better string concatenation performance.
// Iterate in sessionOrder to respect the user's custom tab arrangement, on
// EVERY device: mobile used to hoist the active session to the front, from
// when only one tab fit on screen. With five tabs it made the strip jump
// under the user's finger (and renumbered the Alt+N badges) on every full
// rebuild, while the incremental path left the order alone, so the order
// depended on which render path happened to run. Scrolling the active tab
// into view replaces it.
// Build tabs HTML using array for better string concatenation performance
// Iterate in sessionOrder to respect user's custom tab arrangement
// On mobile: put active session first (only one tab visible anyway)
const parts = [];
const tabOrder = this.sessionOrder;
let tabOrder = this.sessionOrder;
if (MobileDetection.getDeviceType() === 'mobile' && this.activeSessionId) {
// Reorder to put active tab first
tabOrder = [this.activeSessionId, ...this.sessionOrder.filter(id => id !== this.activeSessionId)];
}
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -3889,17 +3826,6 @@ class CodemanApp {
container.innerHTML = parts.join('');
// Put the strip back where the user left it, then reveal the active tab
// only when it CHANGED (or on the first paint). Restoring unconditionally
// and revealing conditionally is what lets someone browse the far end of
// the strip while a background rebuild fires, without the active tab ever
// being stranded off-screen after a switch.
container.scrollLeft = prevScrollLeft;
this._lastRenderedActiveTabId = this.activeSessionId;
if (isFirstRender || prevActiveTabId !== this.activeSessionId) {
this._scrollActiveTabIntoView(this.activeSessionId, isFirstRender ? 'auto' : 'smooth');
}
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
-43
View File
@@ -156,47 +156,6 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1;
}
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into
// view. Landing a tab flush against the edge reads as "this is the last one";
// the gap is what tells the user there is more strip to swipe to.
const TAB_SCROLL_REVEAL_PX = 16;
// Phone/tablet tab-strip scroll policy (issue #257). Those breakpoints scroll
// the strip horizontally (desktop wraps to a second row instead and never
// scrolls), so the active tab can sit entirely outside the visible slice with
// no way back except a swipe the user may not know is possible.
//
// Returns the scrollLeft that puts the tab inside the window, clamped to the
// scrollable range, and returns the CURRENT scrollLeft when the tab is already
// visible: callers compare and skip the write, so an already-correct strip is
// never nudged. Pure: the caller measures, this decides.
function computeTabScrollLeft(input) {
const scrollWidth = Number(input?.scrollWidth) || 0;
const clientWidth = Number(input?.clientWidth) || 0;
const maxScroll = Math.max(0, scrollWidth - clientWidth);
if (maxScroll === 0 || clientWidth <= 0) return 0;
const pad = input?.padding == null ? TAB_SCROLL_REVEAL_PX : Number(input.padding) || 0;
const tabLeft = Number(input?.tabLeft) || 0;
const tabWidth = Number(input?.tabWidth) || 0;
const tabRight = tabLeft + tabWidth;
const viewLeft = Math.min(Math.max(Number(input?.scrollLeft) || 0, 0), maxScroll);
const viewRight = viewLeft + clientWidth;
let target = viewLeft;
if (tabWidth + pad >= clientWidth) {
// Tab is as wide as the window (long session name on a narrow phone):
// there is no position that shows all of it plus padding, so align its
// start, since the name matters more than the trailing badges.
target = tabLeft;
} else if (tabLeft - pad < viewLeft) {
target = tabLeft - pad;
} else if (tabRight + pad > viewRight) {
target = tabRight + pad - clientWidth;
}
return Math.min(Math.max(Math.round(target), 0), maxScroll);
}
// COD-134 — Terminal WebSocket reconnect policy.
//
// Decide what to do after a terminal WebSocket closes, given the close `code`
@@ -302,8 +261,6 @@ if (typeof window !== 'undefined') {
window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX,
};
window.CodemanWsReconnect = {
plan: planWsReconnect,
+829 -770
View File
File diff suppressed because it is too large Load Diff
+364 -6
View File
@@ -115,17 +115,13 @@ html.mobile-init .file-browser-panel {
}
/* Compact session tabs — .tabs-two-rows override needed to match
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0).
overscroll-behavior-x keeps a swipe that runs past the last tab inside the
strip: chained to the page it becomes the browser's back gesture, which is
exactly the swipe someone makes reaching for the rightmost tabs (#257). */
specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
.session-tabs,
.session-tabs.tabs-two-rows {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 52px;
gap: 3px;
@@ -647,7 +643,6 @@ html.mobile-init .file-browser-panel {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: none;
max-height: 36px;
gap: 2px;
@@ -685,6 +680,13 @@ html.mobile-init .file-browser-panel {
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
}
/* No orbiting ring on phone tabs (styles.css draws one on desktop/tablet):
the dot is already enlarged to 9px with a glow here, and a 15px ring in a
32px tab would sit on top of the tab name. The glow is the phone's tell. */
.session-tab .tab-status.busy::after {
display: none;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
@@ -3104,3 +3106,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
+709 -5
View File
@@ -1316,11 +1316,31 @@ html[data-line-anim="packet"] .connection-line.line-enter {
.session-tab .tab-status.idle { background: var(--green); }
.session-tab .tab-status.busy {
position: relative;
background: var(--green);
animation: pulse 1.5s infinite;
/* Performance: GPU acceleration for animation */
will-change: opacity;
}
/* Orbiting ring on a WORKING tab: the same `tab-load-spin` circle the desktop
home rail (.home-sessions-dot--working) and the phone overview
(.mobile-overview-dot--working) draw, so "working" reads identically on every
surface. Pseudo-element on purpose — the skin block below sets
`box-shadow: none` on .tab-status.busy to keep tab dots halo-free, and it
outranks any plain class rule here; a ring drawn with a border is untouched by
that and keeps the tabs quiet while still showing motion. Absolutely
positioned, so it never widens the tab or shifts the label. */
.session-tab .tab-status.busy::after {
content: '';
position: absolute;
inset: -3px;
border: 1.5px solid color-mix(in srgb, var(--green) 25%, transparent);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
pointer-events: none;
}
.session-tab .tab-status.error { background: var(--red); }
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
@@ -5765,11 +5785,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 {
@@ -14302,9 +14317,698 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
}
@media (prefers-reduced-motion: reduce) {
.session-tab .tab-status.busy::after,
.home-sessions-row,
.home-sessions-dot,
.home-sessions-dot::after {
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');
});
});
-165
View File
@@ -183,171 +183,6 @@ describe('Tab Navigation', () => {
});
});
// ─── Tab Strip Scrolling (issue #257) ────────────────────────────────────
describe('Tab Strip Scrolling', () => {
/**
* Seed `count` real sessions and render the strip through the production
* code path (_fullRenderSessionTabs), so the tabs carry the real markup,
* widths and CSS rather than hand-built stand-ins.
*/
async function seedTabs(page: Page, count: number, activeIndex = 0): Promise<void> {
await page.evaluate(`(function (n, activeIndex) {
app.sessions.clear();
app.sessionOrder = [];
for (let i = 1; i <= n; i++) {
const id = 'scroll-sess-' + i;
app.sessions.set(id, { id, name: 'w' + i + '-project', status: 'idle', mode: 'claude', workingDir: '/tmp/p' + i });
app.sessionOrder.push(id);
}
app.activeSessionId = app.sessionOrder[activeIndex];
app._lastRenderedActiveTabId = null;
app._fullRenderSessionTabs();
})(${count}, ${activeIndex})`);
await page.waitForTimeout(200);
}
async function stripState(page: Page, sessionId: string) {
return page.evaluate(`(function (id) {
const c = document.getElementById('sessionTabs');
const tab = c.querySelector('.session-tab[data-id="' + id + '"]');
const cRect = c.getBoundingClientRect();
const tRect = tab ? tab.getBoundingClientRect() : null;
return {
scrollLeft: Math.round(c.scrollLeft),
maxScroll: Math.round(c.scrollWidth - c.clientWidth),
order: [...c.querySelectorAll('.session-tab[data-id]')].map((t) => t.dataset.id),
visible: tRect ? tRect.left >= cRect.left - 1 && tRect.right <= cRect.right + 1 : false,
};
})('${sessionId}')`) as Promise<{ scrollLeft: number; maxScroll: number; order: string[]; visible: boolean }>;
}
it('reveals a rightmost tab that selection would otherwise leave off-screen', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
const before = await stripState(page, 'scroll-sess-5');
// Precondition: the strip really does overflow and the last tab is hidden.
expect(before.maxScroll).toBeGreaterThan(0);
expect(before.visible).toBe(false);
// The selection path selectSession() uses (class toggle, no rebuild).
await page.evaluate(`(function () {
app.activeSessionId = 'scroll-sess-5';
app._updateActiveTabImmediate('scroll-sess-5');
})()`);
await page.waitForTimeout(600); // smooth scroll
const after = await stripState(page, 'scroll-sess-5');
expect(after.visible).toBe(true);
expect(after.scrollLeft).toBeGreaterThan(before.scrollLeft);
} finally {
await context.close();
}
});
it('scrolls back to reveal a leftmost tab', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
await page.evaluate(`(function () {
app.activeSessionId = 'scroll-sess-1';
app._updateActiveTabImmediate('scroll-sess-1');
})()`);
await page.waitForTimeout(600);
const after = await stripState(page, 'scroll-sess-1');
expect(after.visible).toBe(true);
expect(after.scrollLeft).toBe(0);
} finally {
await context.close();
}
});
it('keeps the scroll position across an ambient full re-render', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
// User swipes to the end of the strip, then a background rebuild fires
// (a task badge appearing forces the full-render path).
await page.evaluate(`document.getElementById('sessionTabs').scrollLeft = 9999`);
const scrolled = await stripState(page, 'scroll-sess-5');
expect(scrolled.scrollLeft).toBeGreaterThan(0);
await page.evaluate(`(function () {
app.sessions.get('scroll-sess-2').taskStats = { running: 2, total: 3 };
app._fullRenderSessionTabs();
})()`);
await page.waitForTimeout(200);
const after = await stripState(page, 'scroll-sess-5');
expect(after.scrollLeft).toBe(scrolled.scrollLeft);
} finally {
await context.close();
}
});
it('renders tabs in sessionOrder on phones instead of hoisting the active one', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5, 3); // 4th tab active
const state = await stripState(page, 'scroll-sess-4');
expect(state.order).toEqual([
'scroll-sess-1',
'scroll-sess-2',
'scroll-sess-3',
'scroll-sess-4',
'scroll-sess-5',
]);
// ...and the active tab is still brought into view by the render.
expect(state.visible).toBe(true);
} finally {
await context.close();
}
});
it('reaches the last tab with a horizontal touch drag', async () => {
const { context, page } = await createDevicePage(standardPhone, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await seedTabs(page, 5);
const cdp = await context.newCDPSession(page);
const box = await page.locator(SELECTORS.TABS_CONTAINER).boundingBox();
if (!box) throw new Error('tab strip not found');
const y = box.y + box.height / 2;
const startX = box.x + box.width * 0.85;
const endX = box.x + box.width * 0.1;
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: startX, y }] });
for (let i = 1; i <= 10; i++) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: startX + ((endX - startX) * i) / 10, y }],
});
await page.waitForTimeout(16);
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(400);
const after = await stripState(page, 'scroll-sess-5');
expect(after.scrollLeft).toBeGreaterThan(0);
expect(after.visible).toBe(true);
} finally {
await context.close();
}
});
});
// ─── Swipe Navigation (CDP - Chromium) ───────────────────────────────────
describe('Swipe Navigation (CDP - Chromium)', () => {
+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);
});
+2 -79
View File
@@ -3,28 +3,12 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type ScrollInput = {
scrollLeft?: number;
clientWidth?: number;
scrollWidth?: number;
tabLeft?: number;
tabWidth?: number;
padding?: number;
};
function loadTabOverflowHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (
context.window as {
CodemanTabOverflow: {
shouldAutoWrapTabs: (input: unknown) => boolean;
computeTabScrollLeft: (input: ScrollInput) => number;
TAB_SCROLL_REVEAL_PX: number;
};
}
).CodemanTabOverflow;
return (context.window as { CodemanTabOverflow: { shouldAutoWrapTabs: (input: unknown) => boolean } })
.CodemanTabOverflow;
}
describe('tab overflow layout policy', () => {
@@ -79,64 +63,3 @@ describe('tab overflow layout policy', () => {
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
});
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can
// sit entirely outside the visible slice. These pin the scroll target math that
// _scrollActiveTabIntoView() feeds with measured rects.
describe('mobile tab strip scroll-into-view policy', () => {
// A 5-tab phone strip: 335px visible of 558px of tabs.
const strip = { clientWidth: 335, scrollWidth: 558 };
const pad = 16;
it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => {
const helper = loadTabOverflowHelper();
// Last tab: 458..558, strip parked at 0.
const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 });
// 558 + 16 - 335 = 239, clamped to the 223px maximum.
expect(target).toBe(223);
// The revealed tab is now inside the window.
expect(458).toBeGreaterThanOrEqual(target);
expect(558).toBeLessThanOrEqual(target + strip.clientWidth);
});
it('scrolls left to reveal a tab before the left edge', () => {
const helper = loadTabOverflowHelper();
// First tab: 0..150, strip scrolled to the end.
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0);
// A middle tab partially cut off on the left: reveal it with the sliver.
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad);
});
it('leaves an already-visible tab alone (callers skip the write)', () => {
const helper = loadTabOverflowHelper();
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100);
});
it('never scrolls a strip that fits, and never leaves the scrollable range', () => {
const helper = loadTabOverflowHelper();
// Everything fits: nothing to scroll, whatever the tab geometry says.
expect(
helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 })
).toBe(0);
// Clamped at both ends.
const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 });
expect(low).toBe(0);
const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 });
expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth);
});
it('aligns the start of a tab too wide to fit the window', () => {
const helper = loadTabOverflowHelper();
// 330px tab in a 335px window: no position shows it plus padding.
expect(
helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 })
).toBe(400);
});
it('tolerates missing measurements instead of producing NaN', () => {
const helper = loadTabOverflowHelper();
expect(helper.computeTabScrollLeft({})).toBe(0);
expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0);
expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad);
});
});