mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82aeeaec02 | ||
|
|
ad57394618 |
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
|
||||
@@ -99,6 +99,7 @@ every session or only the active tab.
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
|
||||
@@ -73,6 +73,10 @@ precedence there.
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
|
||||
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
@@ -6709,7 +6709,9 @@ class CodemanApp {
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
|
||||
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
|
||||
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
|
||||
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
line1.appendChild(badge);
|
||||
} else {
|
||||
// The agent's logo: PR #532's slot, the mode id as data (an id with no
|
||||
// logo rule gets the slot's plain dot).
|
||||
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
|
||||
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -369,6 +369,9 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -2216,6 +2216,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
|
||||
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -2577,6 +2578,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -3533,6 +3535,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3669,6 +3672,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3956,6 +3969,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4005,6 +4026,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4253,7 +4282,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
@@ -3010,6 +3010,19 @@ body.solo-mode .btn-lifecycle-log {
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
|
||||
applyTabOrientation() and the pre-paint script in index.html stamp the
|
||||
attribute). The logo leaves the session tabs (header strip, vertical rail,
|
||||
sidebar, grouped rail, phone chips) and the desktop home rail; the status
|
||||
dot and the shell's SH pill stay. Every row it sits in spaces its children
|
||||
with flex `gap`, which skips a display:none item, so no empty slot is left.
|
||||
Tile and split headers (.tile-harness, .split-harness) and the Run menus
|
||||
keep their logos: only these two classes are named here. */
|
||||
html[data-tab-logos='off'] .session-tab .tab-harness,
|
||||
html[data-tab-logos='off'] .home-sessions-harness {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
|
||||
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
|
||||
* false hides the agent logo on session tabs and the desktop home rail
|
||||
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
|
||||
* and the Run menus keep their logos.
|
||||
*/
|
||||
showTabCliLogos: z.boolean().optional(),
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* @fileoverview CLI Logos on Tabs (`showTabCliLogos`): the per-device switch
|
||||
* that hides the agent logo on the session tabs and the desktop home rail.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The switch is a row in App Settings → Appearance → Tabs, right after Tall
|
||||
* Tabs, and the REAL openAppSettings() / saveAppSettings() load and save it
|
||||
* by its id. The load/save contract is getElementById by id, so a renamed
|
||||
* control would otherwise just stop loading or saving, silently.
|
||||
* - It is modelled on tabTwoRows: a display key in the server-settings merge
|
||||
* (a phone never overwrites a desktop's choice) AND an optional boolean in
|
||||
* the .strict() SettingsUpdateSchema (the save PUTs it; a key the schema did
|
||||
* not declare would 400 the whole settings save).
|
||||
* - Default ON on every device; only an explicit false turns it off.
|
||||
* - The pre-paint script in index.html stamps `data-tab-logos` from the same
|
||||
* stored blob, so a reload never flashes the logos it is about to hide, and
|
||||
* a Save stamps it live through applyTabOrientation() without re-rendering
|
||||
* the tabs (the logo spans are always in the markup; CSS hides them).
|
||||
* - The CSS names exactly the tab logo and the home-rail logo. Tile and split
|
||||
* headers, the Run menus and the welcome launchers keep theirs.
|
||||
* - The row reads in Chinese under zh-CN.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss, { type Rule } from 'postcss';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const STYLES = read('styles.css');
|
||||
const MOBILE = read('mobile.css');
|
||||
const I18N = read('i18n.js');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
const PHONE_KEY = 'codeman-app-settings-mobile';
|
||||
|
||||
type Device = 'desktop' | 'mobile';
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
loadAppSettingsFromStorage(): Settings;
|
||||
getDefaultSettings(): Settings;
|
||||
tabCliLogosEnabled(s: Settings): boolean;
|
||||
applyTabOrientation(): void;
|
||||
_cachedAppSettings?: unknown;
|
||||
_apiPut: (path: string, body: Settings) => Promise<unknown>;
|
||||
_fullRenderSessionTabs: ReturnType<typeof vi.fn>;
|
||||
updateTabOverflowMode: ReturnType<typeof vi.fn>;
|
||||
_updateConnectionLinesImmediate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods that run for real. Every other method of the app is a no-op here:
|
||||
* openAppSettings() and saveAppSettings() fan out into the voice, webhook,
|
||||
* tunnel, model and push panels, none of which this setting touches, and the
|
||||
* load/save lines under test sit between those calls.
|
||||
*/
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'tabCliLogosEnabled',
|
||||
'applyTabOrientation',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Methods the load/save paths call that other modules add to the prototype
|
||||
* (terminal-ui.js, panels-ui.js, session-ui.js), which this harness does not
|
||||
* load. Stubbed as own properties.
|
||||
*/
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot (new CodemanApp(): SSE,
|
||||
// timers, the terminal) never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => ${JSON.stringify(device)},
|
||||
isHandheldDevice: () => ${JSON.stringify(device)} !== 'desktop',
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
target._fullRenderSessionTabs = vi.fn();
|
||||
target.updateTabOverflowMode = vi.fn();
|
||||
target._updateConnectionLinesImmediate = vi.fn();
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const doc = win.document;
|
||||
const checkbox = () => doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const storedBlob = () =>
|
||||
JSON.parse(win.localStorage.getItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY) || '{}') as Settings;
|
||||
return { win, doc, app, checkbox, storedBlob };
|
||||
}
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const input = doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const row = input?.closest('.set-row') as HTMLElement;
|
||||
|
||||
it('is a switch in Appearance → Tabs (device scope), right after Tall Tabs', () => {
|
||||
expect(input?.type).toBe('checkbox');
|
||||
expect(input.parentElement!.matches('label.switch.switch-sm')).toBe(true);
|
||||
const group = row.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Tabs');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('device');
|
||||
expect(row.closest('.set-section')!.id).toBe('settings-appearance');
|
||||
expect(row.previousElementSibling!.querySelector('#appSettingsTabTwoRows')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says what it shows, what stays, and what it does not touch', () => {
|
||||
expect(row.querySelector('.set-row-label')!.textContent).toBe('CLI Logos on Tabs');
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
for (const part of ['logo', 'status dot', 'SH badge', 'Tiles', 'split headers', 'Run menus']) {
|
||||
expect(desc, part).toContain(part);
|
||||
}
|
||||
});
|
||||
|
||||
it('is found by the settings search for logo, icon, harness, agent, cli, tab and hide', () => {
|
||||
const words = row.getAttribute('data-search')!.split(/\s+/);
|
||||
for (const word of ['logo', 'icon', 'harness', 'agent', 'cli', 'tab', 'hide']) expect(words, word).toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load and save through settings-ui.js', () => {
|
||||
it('loads the stored value into the switch, and an absent key as on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{ showTabCliLogos: false }, false],
|
||||
[{ showTabCliLogos: true }, true],
|
||||
[{}, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot('desktop', stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves the switch to this device and sends it in the settings PUT, which the schema accepts', async () => {
|
||||
const { app, checkbox, storedBlob } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(storedBlob().showTabCliLogos).toBe(false);
|
||||
expect(app._apiPut).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = (app._apiPut as unknown as { mock: { calls: [string, Settings][] } }).mock.calls[0];
|
||||
expect(path).toBe('/api/settings');
|
||||
expect(body.showTabCliLogos).toBe(false);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('a Save stamps the attribute live', async () => {
|
||||
const { app, checkbox, doc } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
checkbox().checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device like tabTwoRows', () => {
|
||||
it('is a display key: a server value only seeds a device that has none', async () => {
|
||||
// Local choice made: the server's value (another device's) never replaces it,
|
||||
// while a synced key in the same payload does (the merge really ran).
|
||||
const kept = await boot('desktop', { showTabCliLogos: true, autoNameSessions: false });
|
||||
const merged = await kept.app.loadAppSettingsFromServer(
|
||||
Promise.resolve({ showTabCliLogos: false, autoNameSessions: true })
|
||||
);
|
||||
expect(merged.showTabCliLogos).toBe(true);
|
||||
expect(merged.autoNameSessions).toBe(true);
|
||||
|
||||
// A fresh device takes the server's value as its seed.
|
||||
const fresh = await boot('desktop');
|
||||
expect(
|
||||
(await fresh.app.loadAppSettingsFromServer(Promise.resolve({ showTabCliLogos: false }))).showTabCliLogos
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is an optional boolean in SettingsUpdateSchema, and nothing else passes', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({}).success).toBe(true);
|
||||
for (const bad of ['off', 'false', 0, 1, null, {}]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: bad }).success, JSON.stringify(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to on, on a desktop and on a phone', async () => {
|
||||
for (const device of ['desktop', 'mobile'] as const) {
|
||||
const { app, checkbox } = await boot(device);
|
||||
expect(app.tabCliLogosEnabled(app.loadAppSettingsFromStorage()), device).toBe(true);
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, device).toBe(true);
|
||||
}
|
||||
// The phone's defaults blob carries it explicitly, like its other tab keys.
|
||||
expect((await boot('mobile')).app.getDefaultSettings().showTabCliLogos).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off', async () => {
|
||||
const { app } = await boot();
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: false })).toBe(false);
|
||||
for (const value of [true, undefined, 'off', 0, null]) {
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: value }), String(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTabOrientation() stamps data-tab-logos without re-rendering', () => {
|
||||
async function settled(stored: Settings) {
|
||||
const booted = await boot('desktop', stored);
|
||||
booted.app.applyTabOrientation();
|
||||
for (const fn of [
|
||||
booted.app._fullRenderSessionTabs,
|
||||
booted.app.updateTabOverflowMode,
|
||||
booted.app._updateConnectionLinesImmediate,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
const apply = (next: Settings) => {
|
||||
booted.win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(next));
|
||||
delete booted.app._cachedAppSettings;
|
||||
booted.app.applyTabOrientation();
|
||||
};
|
||||
return { ...booted, apply };
|
||||
}
|
||||
|
||||
it('flips the attribute and leaves the tab markup alone', async () => {
|
||||
const { doc, app, apply } = await settled({ showTabCliLogos: true });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-measures the strip wrap and re-anchors the lines on a flip, and only on a flip', async () => {
|
||||
// Every agent tab just got narrower (or wider) with no render behind it: the
|
||||
// one-row wrap decision and the lines drawn from tab rects are stale.
|
||||
const { app, apply } = await settled({ showTabCliLogos: true });
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: true });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(2);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(2);
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pre-paint script in index.html', () => {
|
||||
// The layout one: there is an earlier inline script with the same opening.
|
||||
const PRE_PAINT =
|
||||
[...INDEX.matchAll(/<script>(try\{var m=window\.innerWidth[\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.find((src) => src.includes('dataset.tabStateOrder')) ?? '';
|
||||
|
||||
/**
|
||||
* Runs the real inline script in a fresh window as `device` (a phone is a
|
||||
* 393px viewport, the width test the script uses), with `blob` stored under
|
||||
* that device's key, or under `storeAs`'s key when given.
|
||||
*/
|
||||
function prePaint(blob: string | null, device: Device = 'desktop', storeAs: Device = device) {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const win = dom.window as unknown as Window & { eval(src: string): void };
|
||||
Object.defineProperty(win, 'innerWidth', { value: device === 'desktop' ? 1440 : 393, configurable: true });
|
||||
if (blob !== null) win.localStorage.setItem(storeAs === 'desktop' ? DESKTOP_KEY : PHONE_KEY, blob);
|
||||
win.eval(PRE_PAINT);
|
||||
return win.document.documentElement;
|
||||
}
|
||||
|
||||
it('is found (the checks below are not vacuous)', () => {
|
||||
expect(PRE_PAINT).toContain('localStorage.getItem(k)');
|
||||
});
|
||||
|
||||
it('stamps off from the stored setting, on a desktop and on a phone', () => {
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false })).dataset.tabLogos).toBe('off');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile').dataset.tabLogos).toBe('off');
|
||||
});
|
||||
|
||||
it('leaves the logos on otherwise', () => {
|
||||
expect(prePaint(null).dataset.tabLogos).toBe('on');
|
||||
expect(prePaint('{}').dataset.tabLogos).toBe('on');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: true })).dataset.tabLogos).toBe('on');
|
||||
// A phone reads its own blob: a desktop's choice does not reach it.
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile', 'desktop').dataset.tabLogos).toBe('on');
|
||||
});
|
||||
|
||||
it('falls back to on, beside the other fallbacks, when the stored blob does not parse', () => {
|
||||
const root = prePaint('{not json');
|
||||
expect(root.dataset.tabLogos).toBe('on');
|
||||
expect(root.dataset.tabArrangement).toBe('classic');
|
||||
expect(root.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the CSS', () => {
|
||||
const rulesNaming = (css: string, pattern: RegExp) => {
|
||||
const found: Rule[] = [];
|
||||
postcss.parse(css).walkRules((rule: Rule) => {
|
||||
if (pattern.test(rule.selector)) found.push(rule);
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const selectorsOf = (rule: Rule) => rule.selectors.map((s) => s.replace(/\s+/g, ' ').trim());
|
||||
|
||||
it('hides the tab logo and the home-rail logo under the attribute, in one rule, and nothing else', () => {
|
||||
const rules = rulesNaming(STYLES, /data-tab-logos/);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(selectorsOf(rules[0])).toEqual([
|
||||
"html[data-tab-logos='off'] .session-tab .tab-harness",
|
||||
"html[data-tab-logos='off'] .home-sessions-harness",
|
||||
]);
|
||||
const decls: string[] = [];
|
||||
rules[0].walkDecls((d) => {
|
||||
decls.push(`${d.prop}: ${d.value}${d.important ? ' !important' : ''}`);
|
||||
});
|
||||
expect(decls).toEqual(['display: none']);
|
||||
expect(rules[0].parent?.type).toBe('root');
|
||||
});
|
||||
|
||||
it('leaves the tile and split headers, the Run menus and the welcome launchers their logos', () => {
|
||||
const selectors = rulesNaming(STYLES, /data-tab-logos/)
|
||||
.flatMap(selectorsOf)
|
||||
.join(' ');
|
||||
for (const other of ['tile-harness', 'split-harness', 'run-mode-option', 'welcome', 'mobile-overview']) {
|
||||
expect(selectors, other).not.toContain(other);
|
||||
}
|
||||
// Not the shared slot either: the Run menus draw the same `.run-mode-dot`.
|
||||
expect(selectors).not.toMatch(/(^|\s)\.run-mode-dot/);
|
||||
});
|
||||
|
||||
it('nothing on a phone or in a skin brings the logo back over the rule', () => {
|
||||
expect(MOBILE).not.toContain('data-tab-logos');
|
||||
for (const css of [STYLES, MOBILE]) {
|
||||
for (const rule of rulesNaming(css, /tab-harness|home-sessions-harness|run-mode-dot/)) {
|
||||
rule.walkDecls('display', (d) => {
|
||||
// The only other display rule on the logo is the compact rail's rename
|
||||
// rule, which hides it too.
|
||||
expect(d.value, rule.selector).toBe('none');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh-CN', () => {
|
||||
const rowText = (doc: Document) => {
|
||||
const row = doc.getElementById('appSettingsShowTabCliLogos')!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
};
|
||||
const english = rowText(new JSDOM(INDEX).window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowText(dom.window.document);
|
||||
/** What may stay Latin: the CLI and SH names. */
|
||||
const leftover = (text: string) => text.replace(/\b(CLI|SH|Shell)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
expect(chinese.label).toBe('标签页上的 CLI 图标');
|
||||
expect(chinese.description).not.toBe(english.description);
|
||||
expect(leftover(chinese.label)).toEqual([]);
|
||||
expect(leftover(chinese.description)).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const key of [english.label, english.description]) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect(I18N.match(new RegExp(`^\\s*(?:'${escaped}'|"${escaped}"):`, 'gm')) ?? [], key).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user