mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
feat(settings): put Session Options on the same surface as App Settings
Session Options was the last modal still wearing the old chrome: a strip of top tabs over `.form-row` stacks, sitting next to a settings modal that had just been rebuilt around a rail and grouped row cards. It now uses the same surface. The `set-*` rules move from `#appSettingsModal` to `:is(#appSettingsModal, #sessionOptionsModal)`. An `:is()` list takes the specificity of its most specific argument, and both arguments are ids, so every rule keeps exactly the weight it had - nothing downstream shifts in the cascade. What the two modals do NOT share is what the rail means: - App Settings stays a table of contents over one scrolling document. - Session Options switches: one `.set-section` visible, `.hidden` on the rest. Summary owns its own scroller and Respawn is long, so stacking them into a single document would bury both. `switchOptionsTab` now queries `.set-rail-item` (it read `.modal-tab-btn` before) and resets the document scroll, so a switched-to section starts at its own top. Phones get a horizontal, scrollable rail strip rather than App Settings' sticky jump pill, which Session Options has no equivalent of. That is close to the tab bar it replaces, so the phone gesture is unchanged. Content is regrouped into the row language - label, description, control pinned right - across all four sections: usage limits / respawn loop / cycle steps / loop control, identity / token management / this session, tracker / limits, and the summary timeline. The three cycle-step checkboxes became chips, which is why `_syncSettingsChips` now covers both modals and Session Options registers one delegated change listener per page for them. Every id and handler the JS reads is preserved, and the component classes it queries (`.duration-preset-btn`, `.duration-custom-input`, `.color-swatch`, `.respawn-status-text`, `.run-summary-filters .filter-btn`) are untouched. `data-claude-only` moved onto the rail entries, so external-CLI sessions still lose Respawn and Ralph and land on Context. `.modal-tabs`/`.modal-tab-btn`/`.modal-tab-content` now belong to #createCaseModal alone. test/session-options-structure.test.ts pins the rail to section pairing, the ids openSessionOptions reads, the one-visible-section invariant and the Claude-only entries.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Session Options structural guard.
|
||||
*
|
||||
* The modal shares the `set-*` settings surface with App Settings, but its rail
|
||||
* is a real switcher: switchOptionsTab shows one `.set-section` and hides the
|
||||
* rest. Like App Settings, its load/save path is `getElementById` by a fixed set
|
||||
* of ids, so dropping or renaming an element in the markup fails silently — the
|
||||
* option just stops loading, or stops being written back.
|
||||
*
|
||||
* These tests read the REAL session-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 sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
|
||||
|
||||
/** The Session Options markup, so assertions can't be satisfied elsewhere. */
|
||||
function optionsModal(): string {
|
||||
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
/** Body of a session-ui.js method, by name. */
|
||||
function methodBody(signature: string): string {
|
||||
const start = sessionUi.indexOf(`\n ${signature} {`);
|
||||
expect(start, `${signature} not found in session-ui.js`).toBeGreaterThan(-1);
|
||||
return sessionUi.slice(start, sessionUi.indexOf('\n },', start));
|
||||
}
|
||||
|
||||
const TABS = ['respawn', 'context', 'ralph', 'summary'];
|
||||
|
||||
describe('Session Options modal structure', () => {
|
||||
it('keeps every element openSessionOptions and switchOptionsTab touch by id', () => {
|
||||
const modal = optionsModal();
|
||||
const ids = new Set<string>();
|
||||
for (const sig of ['openSessionOptions(sessionId)', 'switchOptionsTab(tabName)', 'getRalphConfig()']) {
|
||||
for (const m of methodBody(sig).matchAll(/getElementById\('([A-Za-z0-9_-]+)'\)/g)) ids.add(m[1]);
|
||||
}
|
||||
// openSessionOptions also drives elements outside this modal (tabs, toasts);
|
||||
// only the ones it expects to find in here are this file's contract.
|
||||
const outside = new Set(['sessionOptionsDoc']);
|
||||
const missing = [...ids].filter((id) => !outside.has(id) && !modal.includes(`id="${id}"`));
|
||||
expect(missing).toEqual([]);
|
||||
expect(modal).toContain('id="sessionOptionsDoc"');
|
||||
});
|
||||
|
||||
it('pairs each rail entry with exactly one section, in the same order', () => {
|
||||
const modal = optionsModal();
|
||||
const rail = [...modal.matchAll(/class="set-rail-item[^"]*" data-tab="([a-z]+)"/g)].map((m) => m[1]);
|
||||
expect(rail).toEqual(TABS);
|
||||
for (const tab of TABS) {
|
||||
const hits = modal.split(`id="${tab}-tab"`).length - 1;
|
||||
expect(hits, `section ${tab}-tab should exist exactly once`).toBe(1);
|
||||
}
|
||||
// switchOptionsTab queries the rail by THIS class; `.modal-tab-btn` here
|
||||
// would silently stop the active marker from moving.
|
||||
expect(methodBody('switchOptionsTab(tabName)')).toContain("'#sessionOptionsModal .set-rail-item'");
|
||||
expect(methodBody('openSessionOptions(sessionId)')).toContain('.set-rail-item[data-tab="ralph"]');
|
||||
});
|
||||
|
||||
it('opens with exactly one section visible, the rest hidden', () => {
|
||||
const modal = optionsModal();
|
||||
const visible = TABS.filter((t) => modal.includes(`<section class="set-section" id="${t}-tab"`));
|
||||
expect(visible).toEqual(['respawn']);
|
||||
for (const t of TABS.filter((t) => t !== 'respawn')) {
|
||||
expect(modal).toContain(`<section class="set-section hidden" id="${t}-tab"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the Claude-only rail entries marked, so external CLIs lose them', () => {
|
||||
const modal = optionsModal();
|
||||
for (const tab of ['respawn', 'ralph']) {
|
||||
const entry = modal.match(new RegExp(`<button[^>]*data-tab="${tab}"[^>]*>`))?.[0] ?? '';
|
||||
expect(entry, `${tab} rail entry`).toContain('data-claude-only');
|
||||
}
|
||||
expect(modal.match(/<button[^>]*data-tab="context"[^>]*>/)?.[0]).not.toContain('data-claude-only');
|
||||
});
|
||||
|
||||
it('uses the shared settings surface rather than the modal-tab chrome', () => {
|
||||
const modal = optionsModal();
|
||||
expect(modal).toContain('class="modal-content modal-lg set-shell"');
|
||||
expect(modal).toContain('class="set-body"');
|
||||
expect(modal).not.toContain('class="modal-tabs"');
|
||||
expect(modal).not.toContain('modal-tab-btn');
|
||||
expect(modal).not.toContain('modal-tab-content');
|
||||
// The `set-*` rules are shared by both modals through one :is() scope.
|
||||
const css = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal) .set-row {');
|
||||
expect(css).toContain('#sessionOptionsModal .set-section.hidden {');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user