feat(tabs): COD-358 add resizable vertical session rail

This commit is contained in:
Aamer Akhter
2026-08-23 12:21:02 -04:00
parent 88bb98de43
commit e3a2fb767f
26 changed files with 1856 additions and 36 deletions
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename.
+3
View File
@@ -19,6 +19,9 @@
* why these are a runnable suite (`npm run test:browser`) rather than skipped. * why these are a runnable suite (`npm run test:browser`) rather than skipped.
*/ */
export const BROWSER_TEST_GLOBS = [ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-resize.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts', 'test/inline-rename.test.ts',
'test/opencode-resize.test.ts', 'test/opencode-resize.test.ts',
'test/webgl-fallback.test.ts', 'test/webgl-fallback.test.ts',
+2
View File
@@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'); run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
'input-cjk.js', 'input-cjk.js',
'sanitize-html.js', 'sanitize-html.js',
'app.js', 'app.js',
'tab-rail-resize.js',
'terminal-ui.js', 'terminal-ui.js',
'respawn-ui.js', 'respawn-ui.js',
'ralph-panel.js', 'ralph-panel.js',
+68 -10
View File
@@ -919,6 +919,8 @@ class CodemanApp {
// Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder) // Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder)
// and then applies the sidebar variant on top — do not call both. // and then applies the sidebar variant on top — do not call both.
this.applySessionListLayout(); this.applySessionListLayout();
this.applyTabOrientation();
this.initTabRailResize?.();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
this.applyLineageLineSettings?.(); this.applyLineageLineSettings?.();
this._installLineageStripScrollListener?.(); this._installLineageStripScrollListener?.();
@@ -3756,6 +3758,18 @@ class CodemanApp {
return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header'; return layout === 'sidebar' || layout === 'sidebar-rich' ? layout : 'header';
} }
resolveSessionSidebarFontSize(value) {
const size = Number(value);
return Number.isInteger(size) && size >= 11 && size <= 18 ? size : 14;
}
applySessionSidebarFontSize(settings = null) {
const resolvedSettings = settings ?? this.loadAppSettingsFromStorage();
const size = this.resolveSessionSidebarFontSize(resolvedSettings?.sessionSidebarFontSize);
document.documentElement.style.setProperty('--session-sidebar-name-font-size', `${size}px`);
return size;
}
/** /**
* Reads the APPLIED layout off <html>, not the settings blob: this is called * Reads the APPLIED layout off <html>, not the settings blob: this is called
* per dragover event and per tab in render loops, and getSessionListLayout() * per dragover event and per tab in render loops, and getSessionListLayout()
@@ -3767,6 +3781,15 @@ class CodemanApp {
return document.documentElement.dataset.sessionList === 'sidebar'; return document.documentElement.dataset.sessionList === 'sidebar';
} }
_tabOrientation() {
return document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
}
shouldInlineSessionActions() {
if (this.isSessionSidebarActive()) return !this.isSessionSidebarCollapsed();
return this._tabOrientation() === 'vertical' && !document.documentElement.classList.contains('tab-rail-compact');
}
/** /**
* True when the sidebar is showing the DETAILED rows: the home screen's * True when the sidebar is showing the DETAILED rows: the home screen's
* per-session line ("created 3d ago · working 12m") plus a status pill. * per-session line ("created 3d ago · working 12m") plus a status pill.
@@ -3859,17 +3882,22 @@ class CodemanApp {
*/ */
applySessionListLayout() { applySessionListLayout() {
const mode = this.getSessionListLayout(); const mode = this.getSessionListLayout();
this.applySessionSidebarFontSize();
// 'sidebar' and 'sidebar-rich' are the same column; only row detail differs. // 'sidebar' and 'sidebar-rich' are the same column; only row detail differs.
const sidebar = mode === 'sidebar' || mode === 'sidebar-rich'; const sidebar = mode === 'sidebar' || mode === 'sidebar-rich';
const collapsed = this.isSessionSidebarCollapsed(); const collapsed = this.isSessionSidebarCollapsed();
const prevMode = document.documentElement.dataset.sessionList; const prevMode = document.documentElement.dataset.sessionList;
const prevDetail = document.documentElement.dataset.sidebarDetail; const prevDetail = document.documentElement.dataset.sidebarDetail;
const prevCollapsed = document.documentElement.dataset.sidebar;
const tabsEl = document.getElementById('sessionTabs'); const tabsEl = document.getElementById('sessionTabs');
const headerHost = document.getElementById('sessionTabsHost'); const headerHost = document.getElementById('sessionTabsHost');
const sidebarList = document.getElementById('sessionSidebarList'); const sidebarList = document.getElementById('sessionSidebarList');
if (!tabsEl || !headerHost || !sidebarList) return; if (!tabsEl || !headerHost || !sidebarList) return;
const host = sidebar ? sidebarList : headerHost; const rail = document.getElementById('tabRail');
const railOwnsTabs =
!sidebar && document.documentElement.getAttribute('data-tab-orientation') === 'vertical';
const host = sidebar ? sidebarList : railOwnsTabs && rail ? rail : headerHost;
if (tabsEl.parentElement !== host) host.appendChild(tabsEl); if (tabsEl.parentElement !== host) host.appendChild(tabsEl);
document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header'; document.documentElement.dataset.sessionList = sidebar ? 'sidebar' : 'header';
@@ -3878,7 +3906,7 @@ class CodemanApp {
// would let the sidebar CSS style a strip that has nothing to style. // would let the sidebar CSS style a strip that has nothing to style.
document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple'; document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple';
document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded'; document.documentElement.dataset.sidebar = collapsed ? 'collapsed' : 'expanded';
tabsEl.setAttribute('aria-orientation', sidebar ? 'vertical' : 'horizontal'); tabsEl.setAttribute('aria-orientation', host === headerHost ? 'horizontal' : 'vertical');
const btn = document.getElementById('sidebarToggleBtn'); const btn = document.getElementById('sidebarToggleBtn');
if (btn) { if (btn) {
@@ -3931,7 +3959,8 @@ class CodemanApp {
const layoutChanged = const layoutChanged =
prevMode !== document.documentElement.dataset.sessionList || prevMode !== document.documentElement.dataset.sessionList ||
prevDetail !== document.documentElement.dataset.sidebarDetail; prevDetail !== document.documentElement.dataset.sidebarDetail;
if (layoutChanged && prevTall === this._tallTabsEnabled) { const collapseChanged = prevCollapsed !== document.documentElement.dataset.sidebar;
if ((layoutChanged || collapseChanged) && prevTall === this._tallTabsEnabled) {
this._fullRenderSessionTabs(); this._fullRenderSessionTabs();
} }
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
@@ -4474,9 +4503,17 @@ class CodemanApp {
const nameEl = tab.querySelector('.tab-name'); const nameEl = tab.querySelector('.tab-name');
if (nameEl) { if (nameEl) {
const _p = parseSessionPrefix(name); const _p = parseSessionPrefix(name);
const _label = _p && _p.suffix ? _p.suffix : name; if (nameEl.dataset.fullName !== name) {
if (nameEl.textContent !== _label) { nameEl.replaceChildren();
nameEl.textContent = _label; if (_p && _p.suffix) {
const prefix = document.createElement('span');
prefix.className = 'tab-name-prefix';
prefix.textContent = `${_p.prefix}: `;
nameEl.append(prefix, document.createTextNode(_p.suffix));
} else {
nameEl.textContent = name;
}
nameEl.dataset.fullName = name;
tab.title = _p && _p.suffix tab.title = _p && _p.suffix
? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix) ? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix)
: (session.workingDir || ''); : (session.workingDir || '');
@@ -4527,9 +4564,11 @@ class CodemanApp {
// Need to add badge - insert before the action-icon overlay so the // Need to add badge - insert before the action-icon overlay so the
// badge stays a direct child of the tab (outside .tab-actions) // badge stays a direct child of the tab (outside .tab-actions)
const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents); const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents);
const actionsEl = tab.querySelector('.tab-actions'); const actionsEl = tab.querySelector(':scope > .tab-actions');
if (actionsEl) { if (actionsEl) {
actionsEl.insertAdjacentHTML('beforebegin', badgeHtml); actionsEl.insertAdjacentHTML('beforebegin', badgeHtml);
} else {
tab.insertAdjacentHTML('beforeend', badgeHtml);
} }
} else if (minimizedCount === 0 && subagentBadgeEl) { } else if (minimizedCount === 0 && subagentBadgeEl) {
// Count went to 0 - remove badge // Count went to 0 - remove badge
@@ -4587,6 +4626,17 @@ class CodemanApp {
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false; const manualTwoRows = deviceType === 'desktop' ? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false) : false;
const orientation = window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType,
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
if (orientation === 'vertical') {
container.classList.remove('tabs-auto-wrap');
return;
}
if (manualTwoRows || deviceType !== 'desktop') { if (manualTwoRows || deviceType !== 'desktop') {
container.classList.remove('tabs-auto-wrap'); container.classList.remove('tabs-auto-wrap');
return; return;
@@ -4627,6 +4677,7 @@ class CodemanApp {
} }
_fullRenderSessionTabs() { _fullRenderSessionTabs() {
this.closeTabRailActionMenu?.();
if (this._inlineRenameActive) return; if (this._inlineRenameActive) return;
const container = this.$('sessionTabs'); const container = this.$('sessionTabs');
@@ -4704,7 +4755,9 @@ class CodemanApp {
// JUST the description on the tab; the generated w<n>-<case> id moves to the // JUST the description on the tab; the generated w<n>-<case> id moves to the
// tooltip and stays visible in the session settings modal. // tooltip and stays visible in the session settings modal.
const parsedName = parseSessionPrefix(name); const parsedName = parseSessionPrefix(name);
const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name; const tabLabel = parsedName && parsedName.suffix
? `<span class="tab-name-prefix">${escapeHtml(parsedName.prefix)}: </span>${escapeHtml(parsedName.suffix)}`
: escapeHtml(name);
const tabTooltip = parsedName && parsedName.suffix const tabTooltip = parsedName && parsedName.suffix
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix) ? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || ''); : (session.workingDir || '');
@@ -4719,6 +4772,9 @@ class CodemanApp {
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"` ? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}"`
: ''; : '';
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}> parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}"${richData} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''} ${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''} ${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
@@ -4726,7 +4782,8 @@ class CodemanApp {
<span class="tab-info"> <span class="tab-info">
<span class="tab-name-row"> <span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''} ${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span> <span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span> <span class="tab-detached-badge" aria-hidden="true">detached</span>
</span> </span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''} ${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
@@ -4735,7 +4792,7 @@ class CodemanApp {
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''} ${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge} ${subagentBadge}
${ultracodeBadge} ${ultracodeBadge}
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span></span> ${inlineSessionActions ? '' : tabActionsHtml}
</div>`); </div>`);
_tabIdx++; _tabIdx++;
} }
@@ -6025,6 +6082,7 @@ class CodemanApp {
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler // Shared cleanup for all session data — called from both closeSession() and session:deleted handler
_cleanupSessionData(sessionId) { _cleanupSessionData(sessionId) {
this.closeTabRailActionMenu?.();
// If the deleted session is currently being renamed, abort the rename // If the deleted session is currently being renamed, abort the rename
// so the inline <input> doesn't ghost as a stale tab on screen. // so the inline <input> doesn't ghost as a stale tab on screen.
if (this._activeRename?.sessionId === sessionId) { if (this._activeRename?.sessionId === sessionId) {
+80 -7
View File
@@ -156,6 +156,43 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1; return scrollWidth > clientWidth + 1;
} }
function resolveTabOrientation(input) {
if (!input || input.setting !== 'vertical') return 'horizontal';
if (input.deviceType === 'mobile') return 'horizontal';
return 'vertical';
}
const TAB_RAIL_MIN_WIDTH = 208;
const TAB_RAIL_DEFAULT_WIDTH = 256;
const TAB_RAIL_MAX_WIDTH = 360;
function resolveTabRailWidth(input = {}) {
const viewportWidth = Number(input.viewportWidth);
const mainWidth = Number(input.mainWidth);
const minTerminalWidth = Number(input.minTerminalWidth);
const limits = [TAB_RAIL_MAX_WIDTH];
if (Number.isFinite(viewportWidth) && viewportWidth > 0) limits.push(Math.floor(viewportWidth * 0.4));
if (Number.isFinite(mainWidth) && mainWidth > 0 && Number.isFinite(minTerminalWidth) && minTerminalWidth > 0) {
limits.push(Math.floor(mainWidth - minTerminalWidth));
}
const effectiveMax = Math.max(TAB_RAIL_MIN_WIDTH, Math.min(...limits));
const requested = Number(input.width);
const width = Number.isFinite(requested) ? requested : TAB_RAIL_DEFAULT_WIDTH;
return Math.round(Math.min(effectiveMax, Math.max(TAB_RAIL_MIN_WIDTH, width)));
}
function resolveTabRailKeyboardWidth(input = {}) {
let width;
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH;
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
const direction = input.key === 'ArrowLeft' ? -1 : 1;
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
} else return null;
return resolveTabRailWidth({ ...input, width });
}
// Sliver of the neighbouring tab left visible when the strip scrolls a tab into // 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"; // 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. // the gap is what tells the user there is more strip to swipe to.
@@ -243,6 +280,9 @@ const LINEAGE_DIP_MAX_PX = 64;
// apart bled into one thick band instead of reading as three separate lines. // apart bled into one thick band instead of reading as three separate lines.
const LINEAGE_SIBLING_STEP_PX = 8; const LINEAGE_SIBLING_STEP_PX = 8;
const LINEAGE_STRIP_TOLERANCE_PX = 4; const LINEAGE_STRIP_TOLERANCE_PX = 4;
const LINEAGE_VERTICAL_TRACK_INSET_PX = 6;
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled // Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
// (session-lineage.js). Every arc leaving one tab shares its colour however many // (session-lineage.js). Every arc leaving one tab shares its colour however many
// workers it spawns; a child that spawns in turn gets its own for the arcs below it. // workers it spawns; a child that spawns in turn gets its own for the arcs below it.
@@ -265,21 +305,44 @@ function computeLineagePath(input) {
const ch = Number(child.height) || 0; const ch = Number(child.height) || 0;
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null; if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
const px = Number(parent.left) + pw / 2; const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
const cx = Number(child.left) + cw / 2;
if (!Number.isFinite(px) || !Number.isFinite(cx)) return null;
const strip = input?.strip; const strip = input?.strip;
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pLeft = Number(parent.left);
const cLeft = Number(child.left);
const pTop = Number(parent.top);
const cTop = Number(child.top);
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
if (orientation === 'vertical') {
const py = pTop + ph / 2;
const cy = cTop + ch / 2;
if (strip && Number(strip.height) > 0) {
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
if (py < min || py > max || cy < min || cy > max) return null;
}
const stripLeft =
strip && Number.isFinite(Number(strip.left))
? Number(strip.left)
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
const requestedTrack =
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
return { d, endX: cLeft, endY: cy, sameRow: false };
}
const px = pLeft + pw / 2;
const cx = cLeft + cw / 2;
if (strip && Number(strip.width) > 0) { if (strip && Number(strip.width) > 0) {
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX; const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX; const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
if (px < min || px > max || cx < min || cx > max) return null; if (px < min || px > max || cx < min || cx > max) return null;
} }
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
const pTop = Number(parent.top);
const pBottom = pTop + ph; const pBottom = pTop + ph;
const cTop = Number(child.top);
const cBottom = cTop + ch; const cBottom = cTop + ch;
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2; const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
@@ -617,9 +680,17 @@ if (typeof window !== 'undefined') {
window.shouldSkipWebGL = shouldSkipWebGL; window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = { window.CodemanTabOverflow = {
shouldAutoWrapTabs, shouldAutoWrapTabs,
resolveTabOrientation,
computeTabScrollLeft, computeTabScrollLeft,
TAB_SCROLL_REVEAL_PX, TAB_SCROLL_REVEAL_PX,
}; };
window.CodemanTabRail = {
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
resolveWidth: resolveTabRailWidth,
resolveKeyboardWidth: resolveTabRailKeyboardWidth,
};
window.CodemanWsReconnect = { window.CodemanWsReconnect = {
plan: planWsReconnect, plan: planWsReconnect,
}; };
@@ -628,6 +699,8 @@ if (typeof window !== 'undefined') {
DIP_MIN_PX: LINEAGE_DIP_MIN_PX, DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
DIP_MAX_PX: LINEAGE_DIP_MAX_PX, DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX, SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
COLORS: LINEAGE_COLORS, COLORS: LINEAGE_COLORS,
}; };
window.CodemanConnectionLoss = { window.CodemanConnectionLoss = {
+4 -2
View File
@@ -87,12 +87,14 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Width-driven, like every other layout decision in the app. Explicitly yields * Width-driven, like every other layout decision in the app. Explicitly yields
* to the phone overview: that surface already lists the same sessions, and two * to the phone overview and persistent vertical tab rail: those surfaces already
* lists of the same thing on one screen is worse than none. * list the same sessions, and two lists of the same thing on one screen is worse
* than none.
*/ */
shouldShowHomeSessions() { shouldShowHomeSessions() {
if (this.isSoloWindow) return false; if (this.isSoloWindow) return false;
if (this.shouldUseMobileOverview?.()) return false; if (this.shouldUseMobileOverview?.()) return false;
if (document.documentElement.getAttribute('data-tab-orientation') === 'vertical') return false;
// The sidebar layout already docks the full session list flush left at full // The sidebar layout already docks the full session list flush left at full
// height — the rail would render the same list right next to it (and z-wise // height — the rail would render the same list right next to it (and z-wise
// UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay). // UNDER it: sidebar 11, welcome overlay 10, rail inside the overlay).
+2
View File
@@ -232,6 +232,8 @@
'Redraw Terminal Button': '重绘终端按钮', 'Redraw Terminal Button': '重绘终端按钮',
'Tab Bar': '标签栏', 'Tab Bar': '标签栏',
'Session List Layout': '会话列表布局', 'Session List Layout': '会话列表布局',
'Session Name Font Size': '会话名称字体大小',
'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。',
'Header tab strip': '顶栏标签条', 'Header tab strip': '顶栏标签条',
'Left sidebar': '左侧边栏', 'Left sidebar': '左侧边栏',
'Left sidebar simple': '左侧边栏(简洁)', 'Left sidebar simple': '左侧边栏(简洁)',
+52 -1
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate 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 here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. --> 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 L=JSON.parse(localStorage.getItem(k)||'{}').sessionListLayout;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';}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';}</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';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';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style> <style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)} .loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -358,6 +358,20 @@
<!-- Main Terminal Area --> <!-- Main Terminal Area -->
<main class="main"> <main class="main">
<aside class="tab-rail" id="tabRail" aria-label="Session navigation">
<div
id="tabRailResizeHandle"
class="tab-rail-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize session rail"
aria-valuemin="208"
aria-valuemax="360"
aria-valuenow="256"
tabindex="0"
></div>
</aside>
<!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in <!-- Collapsible session sidebar (opt-in layout). Deliberately EMPTY in
markup: applySessionListLayout() moves #sessionTabs in here, so the markup: applySessionListLayout() moves #sessionTabs in here, so the
vertical list is the exact same DOM node as the header strip and every vertical list is the exact same DOM node as the header strip and every
@@ -509,6 +523,7 @@
desktop (which never loads mobile.css) can never render it. --> desktop (which never loads mobile.css) can never render it. -->
<div class="mobile-overview" id="mobileOverview" hidden></div> <div class="mobile-overview" id="mobileOverview" hidden></div>
</main> </main>
<div class="tab-rail-resize-shield" id="tabRailResizeShield" aria-hidden="true" hidden></div>
<!-- Project Insights Panel (shows file-viewing Bash commands) --> <!-- Project Insights Panel (shows file-viewing Bash commands) -->
<div class="project-insights-panel" id="projectInsightsPanel"> <div class="project-insights-panel" id="projectInsightsPanel">
@@ -1869,6 +1884,29 @@
<div class="set-group"> <div class="set-group">
<div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div> <div class="set-group-head"><h4>Tabs</h4><span class="set-scope">device</span></div>
<div class="set-group-body"> <div class="set-group-body">
<div class="set-row has-field" data-search="tab orientation horizontal vertical side rail">
<div class="set-row-text">
<span class="set-row-label">Tab Orientation</span>
<span class="set-row-desc">Keep tabs in the header or place them beside the terminal. Phones stay horizontal.</span>
</div>
<select id="appSettingsTabOrientation" class="set-select">
<option value="horizontal">Horizontal (top)</option>
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Width</span>
<span class="set-row-desc">Set the preferred rail width for this device.</span>
</div>
<select id="appSettingsTabRailWidth" class="set-select">
<option value="208">Compact (208px)</option>
<option value="256">Default (256px)</option>
<option value="320">Wide (320px)</option>
<option value="360">Maximum (360px)</option>
<option value="custom" disabled>Custom</option>
</select>
</div>
<div class="set-row has-field" data-search="session list layout sidebar tab strip vertical"> <div class="set-row has-field" data-search="session list layout sidebar tab strip vertical">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Session List Layout</span> <span class="set-row-label">Session List Layout</span>
@@ -1880,6 +1918,18 @@
<option value="sidebar-rich">Left sidebar</option> <option value="sidebar-rich">Left sidebar</option>
</select> </select>
</div> </div>
<div class="set-row has-field" data-search="session sidebar name font size text">
<div class="set-row-text">
<span class="set-row-label" id="appSettingsSessionSidebarFontSizeLabel">Session Name Font Size</span>
<span class="set-row-desc">Adjust only session names in the vertical sidebar.</span>
</div>
<label class="set-range-field" for="appSettingsSessionSidebarFontSize">
<input type="range" id="appSettingsSessionSidebarFontSize" min="11" max="18" step="1" value="14"
aria-labelledby="appSettingsSessionSidebarFontSizeLabel"
oninput="document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent=this.value+' px'">
<output id="appSettingsSessionSidebarFontSizeValue" for="appSettingsSessionSidebarFontSize">14 px</output>
</label>
</div>
<div class="set-row" data-search="tall tabs folder name two rows"> <div class="set-row" data-search="tall tabs folder name two rows">
<div class="set-row-text"> <div class="set-row-text">
<span class="set-row-label">Tall Tabs</span> <span class="set-row-label">Tall Tabs</span>
@@ -3323,6 +3373,7 @@
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. --> <!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script> <script defer src="sanitize-html.js"></script>
<script defer src="app.js"></script> <script defer src="app.js"></script>
<script defer src="tab-rail-resize.js"></script>
<script defer src="terminal-ui.js"></script> <script defer src="terminal-ui.js"></script>
<script defer src="respawn-ui.js"></script> <script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script> <script defer src="ralph-panel.js"></script>
+9 -1
View File
@@ -160,6 +160,8 @@ Object.assign(CodemanApp.prototype, {
const strip = document.getElementById('sessionTabs'); const strip = document.getElementById('sessionTabs');
if (!strip) return; if (!strip) return;
const stripRect = strip.getBoundingClientRect(); const stripRect = strip.getBoundingClientRect();
const orientation =
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
for (const edge of edges) { for (const edge of edges) {
for (const id of [edge.parentId, edge.childId]) { for (const id of [edge.parentId, edge.childId]) {
const key = 'tab:' + id; const key = 'tab:' + id;
@@ -175,7 +177,13 @@ Object.assign(CodemanApp.prototype, {
const childRect = rects.get('tab:' + edge.childId); const childRect = rects.get('tab:' + edge.childId);
if (!parentRect || !childRect) continue; if (!parentRect || !childRect) continue;
const geom = compute({ parent: parentRect, child: childRect, strip: stripRect, depth: edge.depth }); const geom = compute({
parent: parentRect,
child: childRect,
strip: stripRect,
depth: edge.depth,
orientation,
});
if (!geom) continue; // scrolled out of the strip, or a degenerate rect if (!geom) continue; // scrolled out of the strip, or a degenerate rect
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+49 -9
View File
@@ -1791,12 +1791,19 @@ Object.assign(CodemanApp.prototype, {
const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`); const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`);
if (!tabName) return; if (!tabName) return;
this._activeRename?.cancel();
// Prevent tab re-renders from destroying the input while renaming // Prevent tab re-renders from destroying the input while renaming
this._inlineRenameActive = true; this._inlineRenameActive = true;
tabName.classList.add('tab-name-renaming');
const currentName = this.getSessionName(session); const currentName = this.getSessionName(session);
const parsed = parseSessionPrefix(session.name); const parsed = parseSessionPrefix(session.name);
const originalContent = tabName.textContent; const originalContent = tabName.textContent;
const originalChildren = [...tabName.childNodes].map((node) => node.cloneNode(true));
const restoreOriginalChildren = () => {
tabName.replaceChildren(...originalChildren.map((node) => node.cloneNode(true)));
};
// Clear existing content to make room for the input element // Clear existing content to make room for the input element
tabName.textContent = ''; tabName.textContent = '';
while (tabName.firstChild) tabName.removeChild(tabName.firstChild); while (tabName.firstChild) tabName.removeChild(tabName.firstChild);
@@ -1804,6 +1811,7 @@ Object.assign(CodemanApp.prototype, {
// If prefix detected, show it as non-editable label // If prefix detected, show it as non-editable label
if (parsed) { if (parsed) {
const prefixLabel = document.createElement('span'); const prefixLabel = document.createElement('span');
prefixLabel.className = 'tab-rename-prefix';
prefixLabel.textContent = parsed.prefix + ': '; prefixLabel.textContent = parsed.prefix + ': ';
prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;'; prefixLabel.style.cssText = 'color: var(--text-muted); font-size: 0.75rem; white-space: nowrap;';
tabName.appendChild(prefixLabel); tabName.appendChild(prefixLabel);
@@ -1816,36 +1824,67 @@ Object.assign(CodemanApp.prototype, {
input.className = 'tab-rename-input'; input.className = 'tab-rename-input';
// 80px is tuned for the narrow header tab; a full-width sidebar row can and // 80px is tuned for the narrow header tab; a full-width sidebar row can and
// should give the whole line to the input. // should give the whole line to the input.
const renameWidth = this.isSessionSidebarActive?.() ? '100%' : '80px'; const renameWidth = tabName.closest('.tab-rail') ? 'auto' : this.isSessionSidebarActive?.() ? '100%' : '80px';
input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`; input.style.cssText = `width: ${renameWidth}; min-width: 0; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;`;
tabName.appendChild(input); tabName.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
const finishRename = async ({ commit }) => { let editSettled = false;
if (!this._inlineRenameActive) return; // prevent double-fire let invalidated = false;
let completed = false;
const releaseRenderGuard = () => {
if (this._activeRename !== renameHandle) return;
this._inlineRenameActive = false; this._inlineRenameActive = false;
};
const completeCurrentRename = () => {
if (this._activeRename !== renameHandle) return;
completed = true;
releaseRenderGuard();
this._activeRename = null; this._activeRename = null;
this.renderSessionTabs();
};
const cancelRename = () => {
if (invalidated || completed) return;
invalidated = true;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
restoreOriginalChildren();
completeCurrentRename();
};
const finishRename = async ({ commit }) => {
if (editSettled || invalidated) return;
editSettled = true;
tabName.classList.remove('tab-name-renaming');
// Aborted (e.g. the session was deleted mid-rename, or Escape): re-render // Aborted (e.g. the session was deleted mid-rename, or Escape): re-render
// so any ghost DOM is replaced with the canonical tab list, and skip the // so any ghost DOM is replaced with the canonical tab list, and skip the
// API call — a cancel must not fire a stale rename PUT. // API call — a cancel must not fire a stale rename PUT.
if (!commit) { if (!commit) {
this.renderSessionTabs(); cancelRename();
return; return;
} }
if (this._activeRename !== renameHandle) return;
releaseRenderGuard();
const suffix = input.value.trim(); const suffix = input.value.trim();
const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix; const fullName = parsed ? parsed.prefix + (suffix ? ': ' + suffix : '') : suffix;
tabName.textContent = fullName || originalContent; if (fullName === session.name) restoreOriginalChildren();
else tabName.textContent = fullName || originalContent;
// Skip the API call if the session vanished between focus and blur. // Skip the API call if the session vanished between focus and blur.
const stillExists = this.sessions.has(sessionId); const stillExists = this.sessions.has(sessionId);
if (stillExists && fullName !== session.name) { if (stillExists && fullName !== session.name) {
const confirmed = await this._putSessionName(sessionId, fullName); const confirmed = await this._putSessionName(sessionId, fullName);
if (invalidated || this._activeRename !== renameHandle || !this.sessions.has(sessionId)) return;
if (confirmed === null) { if (confirmed === null) {
tabName.textContent = originalContent; restoreOriginalChildren();
this.showToast('Failed to rename', 'error'); this.showToast('Failed to rename', 'error');
} else { } else {
// The re-render below repaints from this.sessions, so the new name has // The re-render below repaints from this.sessions, so the new name has
@@ -1854,14 +1893,15 @@ Object.assign(CodemanApp.prototype, {
} }
} }
// Re-render tabs to restore full tab structure // Re-render tabs to restore full tab structure
this.renderSessionTabs(); completeCurrentRename();
}; };
// Register only after the input is wired so a throw above can't strand state. // Register only after the input is wired so a throw above can't strand state.
this._activeRename = { const renameHandle = {
sessionId, sessionId,
cancel: () => finishRename({ commit: false }), cancel: cancelRename,
}; };
this._activeRename = renameHandle;
input.addEventListener('blur', () => finishRename({ commit: true })); input.addEventListener('blur', () => finishRename({ commit: true }));
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
+63 -1
View File
@@ -400,9 +400,20 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
}) ?? 256;
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value = document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize
);
document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize);
document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`;
// Claude CLI settings // Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow'); const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -2027,8 +2038,13 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value, sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
document.getElementById('appSettingsSessionSidebarFontSize').value
),
skin: document.getElementById('appSettingsSkin').value, skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
@@ -2178,6 +2194,7 @@ Object.assign(CodemanApp.prototype, {
// Re-parents #sessionTabs between header host and sidebar if the layout // Re-parents #sessionTabs between header host and sidebar if the layout
// changed, then calls applyTabWrapSettings() itself — do not call both. // changed, then calls applyTabWrapSettings() itself — do not call both.
this.applySessionListLayout(); this.applySessionListLayout();
this.applyTabOrientation({ settleRailWidth: true });
this.applyLineageLineSettings?.(); this.applyLineageLineSettings?.();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change) this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility(); this.applyMonitorVisibility();
@@ -2423,7 +2440,10 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false, imageWatcherEnabled: false,
ralphTrackerEnabled: false, ralphTrackerEnabled: false,
tabTwoRows: false, tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
sessionListLayout: 'header', sessionListLayout: 'header',
sessionSidebarFontSize: 14,
cjkInputEnabled: false, cjkInputEnabled: false,
terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel terminalWheelLocalScrollback: false, // mobile scrolls via touch, not wheel
webglRendererEnabled: false, // mobile always uses the DOM renderer webglRendererEnabled: false, // mobile always uses the DOM renderer
@@ -2664,6 +2684,48 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
applyTabOrientation(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const sidebarOwnsTabs = this.isSessionSidebarActive?.() === true;
const orientation =
!this.isSoloWindow && !sidebarOwnsTabs && window.CodemanTabOverflow?.resolveTabOrientation
? window.CodemanTabOverflow.resolveTabOrientation({
deviceType: MobileDetection.getDeviceType(),
setting: settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal',
})
: 'horizontal';
const root = document.documentElement;
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
if (!sidebarOwnsTabs && tabsEl && rail && headerHost) {
if (orientation === 'vertical') {
if (tabsEl.parentElement !== rail) rail.appendChild(tabsEl);
} else if (tabsEl.parentElement !== headerHost) {
headerHost.appendChild(tabsEl);
}
}
if (tabsEl) {
tabsEl.setAttribute('aria-orientation', sidebarOwnsTabs || orientation === 'vertical' ? 'vertical' : 'horizontal');
}
const settleRailWidth =
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
this.applyTabRailWidth?.({ settle: settleRailWidth });
if (previous !== orientation) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
}
},
applyTabWrapSettings() { applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
@@ -2897,7 +2959,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontFamily',
'language', 'language',
+293
View File
@@ -62,6 +62,7 @@
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55); --ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--header-height: 36px; --header-height: 36px;
--toolbar-height: 42px; --toolbar-height: 42px;
--tab-rail-width: 256px;
--sidebar-width: 260px; --sidebar-width: 260px;
--sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */ --sidebar-width-rich: 300px; /* detailed rows carry a stamps line as well */
--sidebar-width-collapsed: 44px; /* == --touch-target-min */ --sidebar-width-collapsed: 44px; /* == --touch-target-min */
@@ -574,6 +575,99 @@ body {
background: var(--border-light); background: var(--border-light);
} }
.tab-rail {
display: none;
flex: 0 0 var(--tab-rail-width);
width: var(--tab-rail-width);
min-width: 0;
overflow: hidden;
background: var(--glass-bg);
border-right: 1px solid var(--glass-border);
position: relative;
z-index: 11;
}
html[data-tab-orientation='vertical'] .tab-rail {
display: flex;
flex-direction: column;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tabs {
--lineage-vertical-gutter: 24px;
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
gap: 2px;
padding: 0.35rem;
padding-left: calc(0.35rem + var(--lineage-vertical-gutter));
overflow-x: hidden;
overflow-y: auto;
max-height: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab {
width: 100%;
max-width: none;
justify-content: flex-start;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab > * {
flex-shrink: 0;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-info {
flex: 1 1 auto;
min-width: 0;
}
html[data-tab-orientation='vertical'] .header-right {
margin-left: auto;
}
.tab-rail-resize-handle {
position: absolute;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
width: 16px;
cursor: ew-resize;
touch-action: none;
transition: background-color 0.15s ease;
}
.tab-rail-resize-handle:hover,
.tab-rail-resize-handle:focus-visible {
background: color-mix(in srgb, var(--accent) 24%, transparent);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
html:not([data-tab-orientation='vertical']) .tab-rail-resize-handle {
display: none;
}
.tab-rail-resize-shield:not([hidden]) {
display: block;
position: fixed;
inset: 0;
z-index: 10000;
cursor: ew-resize;
}
body.tab-rail-resizing,
body.tab-rail-resizing * {
cursor: ew-resize !important;
user-select: none !important;
}
@media (prefers-reduced-motion: reduce) {
.tab-rail,
.tab-rail-resize-handle {
transition: none !important;
}
}
.session-tab { .session-tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1421,6 +1515,44 @@ html[data-line-anim="packet"] .connection-line.line-enter {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.session-tab .tab-name-prefix {
display: none;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.25;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name.tab-name-renaming {
display: flex;
align-items: center;
-webkit-box-orient: initial;
-webkit-line-clamp: unset;
line-clamp: unset;
overflow: visible;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
}
/* Tab folder path — hidden by default, shown via .tabs-show-folder on container */ /* Tab folder path — hidden by default, shown via .tabs-show-folder on container */
.session-tab .tab-folder { .session-tab .tab-folder {
font-size: 0.6rem; font-size: 0.6rem;
@@ -2152,6 +2284,123 @@ html[data-line-anim="packet"] .connection-line.line-enter {
align-items: center; align-items: center;
} }
.session-tab .tab-more {
display: none;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.5rem;
padding: 0;
visibility: hidden;
pointer-events: none;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font: 700 1rem/1 monospace;
cursor: pointer;
}
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail .tab-name-row > .tab-actions > .tab-more,
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab.active > .tab-actions > .tab-more {
display: inline-flex;
}
.session-tab:hover > .tab-actions > .tab-more,
.session-tab:focus-within > .tab-actions > .tab-more,
.session-tab.active > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded'] .session-sidebar
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
:is(.session-tab:hover, .session-tab:focus-within, .session-tab.active)
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
display: none;
}
@media (pointer: coarse) {
.session-tab > .tab-actions > .tab-more,
html[data-session-list='sidebar'][data-sidebar='expanded']
.session-sidebar
.session-tab
.tab-name-row
> .tab-actions
> .tab-more,
html[data-tab-orientation='vertical']:not(.tab-rail-compact)
.tab-rail
.session-tab
.tab-name-row
> .tab-actions
> .tab-more {
visibility: visible;
pointer-events: auto;
}
}
.tab-rail-action-menu {
position: fixed;
z-index: 2000;
display: grid;
min-width: 180px;
padding: 0.3rem;
border: 1px solid var(--glass-border);
border-radius: var(--btn-radius);
background: var(--floating-bg);
box-shadow: var(--elevated-shadow);
}
.tab-rail-action-menu button {
padding: 0.45rem 0.6rem;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
}
.tab-rail-action-menu button:hover,
.tab-rail-action-menu button:focus-visible {
background: var(--control-bg-hover);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tab-rail-action-menu .danger {
color: var(--red);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid;
grid-template-columns: minmax(112px, 1fr) 44px;
align-items: center;
gap: 8px;
width: min(220px, 44vw);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field input {
width: 100%;
accent-color: var(--accent);
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field output {
color: var(--text);
font-family: var(--font-mono);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device). /* Pop-out button is opt-in (App Settings → Tab Bar, default off; per-device).
settings-ui.js mirrors the setting as the tabs-show-detach class on <html>. settings-ui.js mirrors the setting as the tabs-show-detach class on <html>.
A tab that is ALREADY detached keeps its icon regardless: it is the A tab that is ALREADY detached keeps its icon regardless: it is the
@@ -14754,6 +15003,10 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: none; display: none;
} }
html[data-tab-orientation='vertical'] .home-sessions {
display: none !important;
}
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the /* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
matchMedia listener must never leave the column overlapping the content. */ matchMedia listener must never leave the column overlapping the content. */
@media (max-width: 1179px) { @media (max-width: 1179px) {
@@ -16530,6 +16783,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
padding: 0 22px 22px; padding: 0 22px 22px;
} }
#sessionOptionsModal #context-tab {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.25rem 1rem;
align-items: start;
}
#sessionOptionsModal .set-section { #sessionOptionsModal .set-section {
padding-top: 18px; padding-top: 18px;
} }
@@ -16547,6 +16807,22 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
font-size: 0.75rem; font-size: 0.75rem;
} }
@media (min-width: 1200px) {
#sessionOptionsModal .modal-content.modal-lg {
width: min(1120px, 96vw);
max-width: min(1120px, 96vw);
}
#sessionOptionsModal #context-tab {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#sessionOptionsModal #context-tab > .set-section-head,
#sessionOptionsModal #context-tab > .set-section-blurb {
grid-column: 1 / -1;
}
}
#sessionOptionsModal .set-group + .set-group { #sessionOptionsModal .set-group + .set-group {
margin-top: 16px; margin-top: 16px;
} }
@@ -16836,10 +17112,27 @@ html[data-session-list="sidebar"] .session-sidebar .tab-info {
min-width: 0; min-width: 0;
} }
html[data-session-list="sidebar"] .session-sidebar .tab-name-row,
html[data-session-list="sidebar"] .session-sidebar .tab-name { html[data-session-list="sidebar"] .session-sidebar .tab-name {
min-width: 0;
max-width: none; max-width: none;
} }
html[data-session-list='sidebar'] .session-sidebar .tab-name {
flex: 0 1 auto;
white-space: nowrap;
font-size: var(--session-sidebar-name-font-size, 14px);
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name {
font-size: var(--session-sidebar-name-font-size, 14px);
}
html[data-session-list='sidebar'] .session-sidebar .tab-actions,
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .tab-actions {
flex-shrink: 0;
}
/* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row /* Reveal-on-hover reads badly on a 40px-tall full-width row, so keep the row
actions permanently visible on the active session — no layout jitter when actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */ the pointer crosses the list. */
+316
View File
@@ -0,0 +1,316 @@
/** @fileoverview Accessible, device-local vertical session-rail sizing. */
Object.assign(CodemanApp.prototype, {
_getTabRailMinimumTerminalWidth() {
const cellWidth = this.terminal?._core?._renderService?.dimensions?.css?.cell?.width;
if (Number.isFinite(cellWidth) && cellWidth > 0) return Math.ceil(cellWidth * 40 + 24);
return 420;
},
_getTabRailBounds() {
const main = document.querySelector('.main');
return {
viewportWidth: window.innerWidth,
mainWidth: main?.clientWidth || window.innerWidth,
minTerminalWidth: this._getTabRailMinimumTerminalWidth(),
};
},
_getCurrentTabRailWidth() {
const fromCss = Number.parseFloat(document.documentElement.style.getPropertyValue('--tab-rail-width'));
if (Number.isFinite(fromCss)) return fromCss;
const measured = document.getElementById('tabRail')?.getBoundingClientRect?.().width;
return Number.isFinite(measured) && measured > 0 ? measured : window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
readTabRailWidthSetting() {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return this._getCurrentTabRailWidth();
if (select.value === 'custom') return Number(select.dataset.currentWidth) || this._getCurrentTabRailWidth();
return Number(select.value) || window.CodemanTabRail?.DEFAULT_WIDTH || 256;
},
syncTabRailWidthSetting(width) {
const select = document.getElementById('appSettingsTabRailWidth');
if (!select) return;
const rounded = Math.round(width);
select.dataset.currentWidth = String(rounded);
const preset = select.querySelector(`option[value="${rounded}"]`);
if (preset) {
select.value = String(rounded);
return;
}
const custom = select.querySelector('option[value="custom"]');
if (custom) custom.textContent = `Custom (${rounded}px)`;
select.value = 'custom';
},
_setTabRailWidth(width) {
const policy = window.CodemanTabRail;
if (!policy) return 256;
const preferred = policy.resolveWidth({ width });
const bounds = this._getTabRailBounds();
const resolved = policy.resolveWidth({ width: preferred, ...bounds });
const effectiveMax = policy.resolveWidth({ width: policy.MAX_WIDTH, ...bounds });
const root = document.documentElement;
root.style.setProperty('--tab-rail-width', `${resolved}px`);
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
handle.setAttribute('aria-valuenow', String(resolved));
}
this.syncTabRailWidthSetting(preferred);
return resolved;
},
_persistTabRailWidth(width) {
const settings = this.loadAppSettingsFromStorage();
if (settings.tabRailWidth === width) return;
settings.tabRailWidth = width;
this.saveAppSettingsToStorage(settings);
},
_claimTabRailResize() {
this._tabRailResizeOwnsObserver = true;
clearTimeout(this._tabRailResizeWatchdog);
clearTimeout(this._tabRailReleaseTimer);
if (this._tabRailReleaseRaf) cancelAnimationFrame(this._tabRailReleaseRaf);
this._armTabRailResizeWatchdog();
if (this._resizeRaf) cancelAnimationFrame(this._resizeRaf);
if (this._resizeTimeout) clearTimeout(this._resizeTimeout);
this._resizeRaf = null;
this._resizeTimeout = null;
},
_armTabRailResizeWatchdog() {
this._tabRailResizeWatchdog = setTimeout(() => {
this._tabRailResizeWatchdog = null;
if (document.body.classList.contains('tab-rail-resizing')) {
this._armTabRailResizeWatchdog();
return;
}
this._tabRailResizeOwnsObserver = false;
}, 1000);
},
_releaseTabRailResize() {
clearTimeout(this._tabRailResizeWatchdog);
this._tabRailResizeWatchdog = null;
const release = () => {
clearTimeout(this._tabRailReleaseTimer);
this._tabRailReleaseTimer = null;
this._tabRailReleaseRaf = null;
this._tabRailResizeOwnsObserver = false;
};
if (typeof requestAnimationFrame === 'function') {
this._tabRailReleaseRaf = requestAnimationFrame(() => {
this._tabRailReleaseRaf = requestAnimationFrame(release);
});
this._tabRailReleaseTimer = setTimeout(release, 250);
} else release();
},
_scheduleTabRailSettle(effective, preferred = effective) {
clearTimeout(this._tabRailSettleTimer);
this._tabRailSettleTimer = setTimeout(async () => {
this._tabRailSettleTimer = null;
this._persistTabRailWidth(preferred);
try {
if (this.activeSessionId && this.sendResize) await this.sendResize(this.activeSessionId);
else this.fitAddon?.fit();
this._updateConnectionLinesImmediate?.();
} catch (error) {
console.warn('Failed to resize terminal after rail resize:', error);
} finally {
this._releaseTabRailResize();
}
}, 150);
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
if (options.persist !== false && requested !== preferred) this._persistTabRailWidth(preferred);
if (options.settle) this._scheduleTabRailSettle(resolved, preferred);
return resolved;
},
_applyTabRailPointerWidth(clientX) {
const main = document.querySelector('.main');
if (!main) return this._getCurrentTabRailWidth();
const previous = this._getCurrentTabRailWidth();
const width = this._setTabRailWidth(clientX - main.getBoundingClientRect().left);
let proposed = null;
try {
proposed = this.fitAddon?.proposeDimensions?.();
} catch {}
return proposed && proposed.cols < 40 ? this._setTabRailWidth(previous) : width;
},
_queueTabRailPointerWidth(clientX) {
this._tabRailPendingClientX = clientX;
if (this._tabRailPointerRaf) return;
this._tabRailPointerRaf = requestAnimationFrame(() => {
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
});
},
_finishTabRailDrag(handle, pointerId) {
if (!document.body.classList.contains('tab-rail-resizing')) return;
if (this._tabRailPointerRaf) {
cancelAnimationFrame(this._tabRailPointerRaf);
this._tabRailPointerRaf = null;
this._tabRailDragWidth = this._applyTabRailPointerWidth(this._tabRailPendingClientX);
}
document.body.classList.remove('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = true;
try {
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
} catch {}
const preferred = window.CodemanTabRail?.resolveWidth({ width: this._tabRailDragWidth }) ?? this._tabRailDragWidth;
this._scheduleTabRailSettle(this._tabRailDragWidth || this._getCurrentTabRailWidth(), preferred);
},
_onTabRailKeyDown(event) {
const width = window.CodemanTabRail?.resolveKeyboardWidth({
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
event.preventDefault();
this._claimTabRailResize();
const effective = this._setTabRailWidth(width);
this._scheduleTabRailSettle(effective, window.CodemanTabRail.resolveWidth({ width }));
},
initTabRailResize() {
const handle = document.getElementById('tabRailResizeHandle');
if (!handle || handle.dataset.ready === '1') return;
handle.dataset.ready = '1';
this.applyTabRailWidth();
handle.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
this.closeTabRailActionMenu();
this._claimTabRailResize();
this._tabRailDragWidth = this._getCurrentTabRailWidth();
this._tabRailPendingClientX = event.clientX;
document.body.classList.add('tab-rail-resizing');
const shield = document.getElementById('tabRailResizeShield');
if (shield) shield.hidden = false;
try {
handle.setPointerCapture(event.pointerId);
} catch {
document.body.classList.remove('tab-rail-resizing');
if (shield) shield.hidden = true;
this._releaseTabRailResize();
}
});
handle.addEventListener('pointermove', (event) => {
if (handle.hasPointerCapture?.(event.pointerId)) this._queueTabRailPointerWidth(event.clientX);
});
handle.addEventListener('pointerup', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('pointercancel', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('lostpointercapture', (event) => this._finishTabRailDrag(handle, event.pointerId));
handle.addEventListener('keydown', (event) => this._onTabRailKeyDown(event));
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
let resizeTimer = null;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
this.applyTabOrientation?.();
this.applyTabRailWidth({ persist: false });
}, 100);
});
},
closeTabRailActionMenu(options = {}) {
const menu = document.querySelector('.tab-rail-action-menu');
const trigger = this._tabRailActionMenuTrigger;
menu?.remove();
if (this._tabRailActionMenuOutside) {
document.removeEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuOutside = null;
}
if (this._tabRailActionMenuViewport) {
window.removeEventListener('resize', this._tabRailActionMenuViewport);
this._tabRailActionMenuViewport = null;
}
this._tabRailActionMenuTrigger = null;
if (options.restoreFocus) trigger?.focus?.();
},
openTabRailActionMenu(event, sessionId) {
event.preventDefault();
event.stopPropagation();
this.closeTabRailActionMenu();
const trigger = event.currentTarget;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Session actions');
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId)
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
: []),
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
button.addEventListener('click', () => {
this.closeTabRailActionMenu();
action.run();
});
menu.appendChild(button);
}
document.body.appendChild(menu);
const rect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
menu.style.left = `${Math.max(8, Math.min(rect.right - menuRect.width, window.innerWidth - menuRect.width - 8))}px`;
menu.style.top = `${Math.max(8, Math.min(rect.bottom + 4, window.innerHeight - menuRect.height - 8))}px`;
this._tabRailActionMenuTrigger = trigger;
this._tabRailActionMenuOutside = (pointerEvent) => {
if (!menu.contains(pointerEvent.target) && pointerEvent.target !== trigger) this.closeTabRailActionMenu();
};
document.addEventListener('pointerdown', this._tabRailActionMenuOutside, true);
this._tabRailActionMenuViewport = () => this.closeTabRailActionMenu();
window.addEventListener('resize', this._tabRailActionMenuViewport, { once: true });
menu.addEventListener('keydown', (keyEvent) => {
const buttons = [...menu.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (keyEvent.key === 'Escape') {
keyEvent.preventDefault();
this.closeTabRailActionMenu({ restoreFocus: true });
} else if (keyEvent.key === 'ArrowDown' || keyEvent.key === 'ArrowUp') {
keyEvent.preventDefault();
const direction = keyEvent.key === 'ArrowDown' ? 1 : -1;
buttons[(index + direction + buttons.length) % buttons.length]?.focus();
}
});
menu.querySelector('button')?.focus();
},
});
+1
View File
@@ -901,6 +901,7 @@ Object.assign(CodemanApp.prototype, {
const MIN_ROWS = 10; const MIN_ROWS = 10;
const throttledResize = () => { const throttledResize = () => {
if (this._tabRailResizeOwnsObserver) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal // once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms. // stays at its old dimensions for up to 300ms.
+4
View File
@@ -956,6 +956,8 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting. // CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(), acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(), tabTwoRows: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
/** /**
* Session list layout. Display key (per-device). * Session list layout. Display key (per-device).
* 'header' = horizontal tab strip * 'header' = horizontal tab strip
@@ -967,6 +969,8 @@ export const SettingsUpdateSchema = z
* on data-sidebar-detail. See applySessionListLayout() in app.js. * on data-sidebar-detail. See applySessionListLayout() in app.js.
*/ */
sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(), sessionListLayout: z.enum(['header', 'sidebar', 'sidebar-rich']).optional(),
/** Session-name text size in vertical navigation. Display key (per-device). */
sessionSidebarFontSize: z.number().int().min(11).max(18).optional(),
agentTeamsEnabled: z.boolean().optional(), agentTeamsEnabled: z.boolean().optional(),
/** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */ /** Model for new Claude sessions (e.g. "claude-fable-5[1m]", "opus[1m]"); takes precedence over opusContext1mEnabled */
claudeModel: z.string().max(50).optional(), claudeModel: z.string().max(50).optional(),
+15 -1
View File
@@ -40,13 +40,16 @@ function fakeElement(): any {
* point: if that reuse ever breaks, these tests stop loading rather than * point: if that reuse ever breaks, these tests stop loading rather than
* quietly testing a divergent copy. * quietly testing a divergent copy.
*/ */
function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512) { function loadHomeSessionsApp(overrides: Record<string, any> = {}, innerWidth = 1512, tabOrientation = 'horizontal') {
const CodemanApp = function CodemanApp(this: any) {}; const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({ const context = vm.createContext({
CodemanApp, CodemanApp,
console, console,
window: { innerWidth }, window: { innerWidth },
document: { document: {
documentElement: {
getAttribute: (name: string) => (name === 'data-tab-orientation' ? tabOrientation : null),
},
getElementById: () => null, getElementById: () => null,
createElement: () => fakeElement(), createElement: () => fakeElement(),
createElementNS: () => fakeElement(), createElementNS: () => fakeElement(),
@@ -198,6 +201,11 @@ describe('home sessions column: gate', () => {
expect(app.shouldShowHomeSessions()).toBe(true); expect(app.shouldShowHomeSessions()).toBe(true);
}); });
it('yields to the persistent rail when the effective tab orientation is vertical', () => {
expect(loadHomeSessionsApp({}, 1512, 'vertical').shouldShowHomeSessions()).toBe(false);
expect(loadHomeSessionsApp({}, 1512, 'horizontal').shouldShowHomeSessions()).toBe(true);
});
it('stays out of a window too narrow to hold it beside the centered content', () => { it('stays out of a window too narrow to hold it beside the centered content', () => {
// Absolutely positioned: below the gate it would overlap the search panel // Absolutely positioned: below the gate it would overlap the search panel
// rather than push it aside. // rather than push it aside.
@@ -238,6 +246,12 @@ describe('home sessions column: wiring', () => {
expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/); expect(css).toMatch(/\.home-sessions\[hidden\]\s*\{\s*display:\s*none;/);
}); });
it('has a CSS backstop that suppresses the homepage rail in vertical mode', () => {
expect(css).toMatch(
/html\[data-tab-orientation='vertical'\]\s+\.home-sessions\s*\{\s*display:\s*none\s*!important;/
);
});
it('reuses the tab-load spinner rather than declaring a second one', () => { it('reuses the tab-load spinner rather than declaring a second one', () => {
// The working ring is the same motion a tab shows while it loads, on both // The working ring is the same motion a tab shows while it loads, on both
// home screens. Re-declaring the keyframes here is how they drift apart. // home screens. Re-declaring the keyframes here is how they drift apart.
+74 -2
View File
@@ -186,6 +186,9 @@ describe('Inline rename input', () => {
renameActiveAfter: !!app._activeRename, renameActiveAfter: !!app._activeRename,
sessionGone: !app.sessions.has('ghost-id'), sessionGone: !app.sessions.has('ghost-id'),
fetchFired, fetchFired,
renameClassActive:
document.querySelector('.tab-name[data-session-id="ghost-id"]')?.classList.contains('tab-name-renaming') ??
false,
}; };
}); });
@@ -194,6 +197,7 @@ describe('Inline rename input', () => {
expect(result.sessionGone).toBe(true); expect(result.sessionGone).toBe(true);
// Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT. // Cancel path skips the API call — deleting a session shouldn't trigger a stale rename PUT.
expect(result.fetchFired).toBe(false); expect(result.fetchFired).toBe(false);
expect(result.renameClassActive).toBe(false);
}); });
it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => { it('Ghost tab: _cleanupSessionData for a DIFFERENT session does NOT cancel rename', async () => {
@@ -386,10 +390,16 @@ describe('Inline rename input', () => {
await new Promise((r) => setTimeout(r, 60)); await new Promise((r) => setTimeout(r, 60));
window.fetch = origFetch; window.fetch = origFetch;
return { mapName: app.sessions.get('no-sse')?.name ?? null }; return {
mapName: app.sessions.get('no-sse')?.name ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="no-sse"]')?.classList.contains('tab-name-renaming') ??
false,
};
}); });
expect(result.mapName).toBe('w9-case: fresh'); expect(result.mapName).toBe('w9-case: fresh');
expect(result.renameClassActive).toBe(false);
}); });
it('A rejected rename restores the old label and leaves app.sessions untouched', async () => { it('A rejected rename restores the old label and leaves app.sessions untouched', async () => {
@@ -426,12 +436,16 @@ describe('Inline rename input', () => {
return { return {
mapName: app.sessions.get('rename-500')?.name ?? null, mapName: app.sessions.get('rename-500')?.name ?? null,
label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null, label: document.querySelector('.tab-name[data-session-id="rename-500"]')?.textContent ?? null,
renameClassActive:
document.querySelector('.tab-name[data-session-id="rename-500"]')?.classList.contains('tab-name-renaming') ??
false,
toasts, toasts,
}; };
}); });
expect(result.mapName).toBe('w9-case'); expect(result.mapName).toBe('w9-case');
expect(result.label).toBe('w9-case'); expect(result.label).toBe('w9-case');
expect(result.renameClassActive).toBe(false);
expect(result.toasts).toContain('Failed to rename'); expect(result.toasts).toContain('Failed to rename');
}); });
@@ -462,10 +476,68 @@ describe('Inline rename input', () => {
wrap.appendChild(tabName); wrap.appendChild(tabName);
document.body.appendChild(wrap); document.body.appendChild(wrap);
app.startInlineRename('second-id'); app.startInlineRename('second-id');
return { firstActive, secondActive: app._activeRename?.sessionId }; return {
firstActive,
secondActive: app._activeRename?.sessionId,
firstRenameClassActive:
document.querySelector('.tab-name[data-session-id="first-id"]')?.classList.contains('tab-name-renaming') ??
false,
};
}); });
expect(result.firstActive).toBe('first-id'); expect(result.firstActive).toBe('first-id');
expect(result.secondActive).toBe('second-id'); expect(result.secondActive).toBe('second-id');
expect(result.firstRenameClassActive).toBe(false);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
await page.evaluate((sessionId) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.tabOrientation = 'vertical';
const rail = document.getElementById('tabRail') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
`<span class="tab-name" data-session-id="${sessionId}">` +
'<span class="tab-name-prefix">w9-case: </span>old</span>';
rail.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'w9-case: old' });
app.startInlineRename(sessionId);
}, id);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).webkitLineClamp)).toBe('none');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
const settled = await page.evaluate((sessionId) => {
const app = (window as unknown as { app: { _activeRename: { cancel: () => void } | null } }).app;
app._activeRename?.cancel();
const label = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`) as HTMLElement;
return {
classActive: label.classList.contains('tab-name-renaming'),
inputPresent: !!label.querySelector('input.tab-rename-input'),
webkitLineClamp: getComputedStyle(label).webkitLineClamp,
};
}, id);
expect(settled).toEqual({ classActive: false, inputPresent: false, webkitLineClamp: '2' });
}); });
}); });
+86 -2
View File
@@ -12,6 +12,10 @@ import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number }; type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null; type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
type Orientation = 'horizontal' | 'vertical';
const lineageJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-lineage.js'), 'utf8');
const stylesCss = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function loadLineageHelper() { function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} }); const context = vm.createContext({ window: {}, globalThis: {} });
@@ -20,7 +24,13 @@ function loadLineageHelper() {
return ( return (
context.window as { context.window as {
CodemanLineage: { CodemanLineage: {
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath; computePath: (input: {
parent: Rect | null;
child: Rect | null;
strip?: Rect;
depth?: number;
orientation?: Orientation;
}) => LineagePath;
DIP_MIN_PX: number; DIP_MIN_PX: number;
DIP_MAX_PX: number; DIP_MAX_PX: number;
SIBLING_STEP_PX: number; SIBLING_STEP_PX: number;
@@ -48,7 +58,11 @@ function loadLineageApp(): { app: LineageApp; sandbox: LineageSandbox } {
globalThis: {}, globalThis: {},
CodemanApp, CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop' }, MobileDetection: { getDeviceType: () => 'desktop' },
document: { getElementById: () => null, createElementNS: () => null }, document: {
documentElement: { getAttribute: () => 'horizontal' },
getElementById: () => null,
createElementNS: () => null,
},
}; };
const context = vm.createContext(sandbox); const context = vm.createContext(sandbox);
for (const file of ['constants.js', 'session-lineage.js']) { for (const file of ['constants.js', 'session-lineage.js']) {
@@ -258,6 +272,76 @@ describe('lineage line geometry', () => {
).toBeNull(); ).toBeNull();
}); });
it('routes vertical tabs through the empty left gutter instead of their shared centerline', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(geom).not.toBeNull();
expect(nums).toHaveLength(5);
expect(nums[0]).toBe(parent.left);
expect(nums[1]).toBe(parent.top + parent.height / 2);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect(nums[3]).toBe(child.top + child.height / 2);
expect(nums[4]).toBe(child.left);
expect(geom.endX).toBe(child.left);
expect(geom.endY).toBe(child.top + child.height / 2);
});
it('offsets vertical sibling tracks without moving either tab endpoint', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 40, width: 260, height: 40 };
const child: Rect = { left: 132, top: 200, width: 260, height: 40 };
const first = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 0 })!;
const second = helper.computePath({ parent, child, strip, orientation: 'vertical', depth: 1 })!;
const numbers = (d: string) => d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(numbers(second.d)[2]).toBeGreaterThan(numbers(first.d)[2]);
expect([second.endX, second.endY]).toEqual([first.endX, first.endY]);
});
it('keeps the same gutter shape when the child sits above its parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 20, width: 320, height: 320 };
const parent: Rect = { left: 132, top: 220, width: 260, height: 40 };
const child: Rect = { left: 132, top: 60, width: 260, height: 40 };
const geom = helper.computePath({ parent, child, strip, orientation: 'vertical' })!;
const nums = geom.d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
expect(nums).toEqual([parent.left, 240, expect.any(Number), 80, child.left]);
expect(nums[2]).toBeGreaterThan(strip.left);
expect(nums[2]).toBeLessThan(parent.left);
expect([geom.endX, geom.endY]).toEqual([child.left, 80]);
});
it('clips vertical lineage by the visible Y range after rail scrolling', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 100, top: 100, width: 320, height: 300 };
const visible: Rect = { left: 132, top: 160, width: 260, height: 40 };
const above: Rect = { left: 132, top: 20, width: 260, height: 40 };
const below: Rect = { left: 132, top: 460, width: 260, height: 40 };
expect(helper.computePath({ parent: above, child: visible, strip, orientation: 'vertical' })).toBeNull();
expect(helper.computePath({ parent: visible, child: below, strip, orientation: 'vertical' })).toBeNull();
expect(
helper.computePath({ parent: visible, child: { ...visible, top: 300 }, strip, orientation: 'vertical' })
).not.toBeNull();
});
it('passes the resolved DOM orientation into geometry and reserves a vertical gutter', () => {
expect(lineageJs).toContain("getAttribute('data-tab-orientation')");
expect(lineageJs).toMatch(/compute\(\{[\s\S]{0,180}orientation/);
const selector = "html[data-tab-orientation='vertical'] .tab-rail .session-tabs {";
const verticalRailBlock = stylesCss.slice(stylesCss.indexOf(selector), stylesCss.indexOf(selector) + 600);
expect(verticalRailBlock).toContain('--lineage-vertical-gutter');
expect(verticalRailBlock).toContain('padding-left');
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => { it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper(); const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) }); const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
+51
View File
@@ -34,6 +34,7 @@ const SCHEMAS = readFileSync(new URL('../src/web/schemas.ts', import.meta.url),
interface LayoutApp { interface LayoutApp {
soloSessionId: string | null; soloSessionId: string | null;
isSoloWindow: boolean;
sessions: Map<string, unknown>; sessions: Map<string, unknown>;
sessionOrder: string[]; sessionOrder: string[];
_tallTabsEnabled?: boolean; _tallTabsEnabled?: boolean;
@@ -44,6 +45,7 @@ interface LayoutApp {
isSessionSidebarActive(): boolean; isSessionSidebarActive(): boolean;
isSessionSidebarCollapsed(): boolean; isSessionSidebarCollapsed(): boolean;
applySessionListLayout(): void; applySessionListLayout(): void;
applyTabOrientation(): void;
toggleSessionSidebar(): void; toggleSessionSidebar(): void;
updateSidebarCount(): void; updateSidebarCount(): void;
closeSessionSidebarOnHandheld(): void; closeSessionSidebarOnHandheld(): void;
@@ -86,6 +88,7 @@ const SHELL = `
</div> </div>
</header> </header>
<main class="main"> <main class="main">
<div class="tab-rail" id="tabRail"></div>
<aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions"> <aside class="session-sidebar" id="sessionSidebar" aria-label="Sessions">
<div class="session-sidebar-head"> <div class="session-sidebar-head">
<span class="session-sidebar-title">Sessions</span> <span class="session-sidebar-title">Sessions</span>
@@ -154,6 +157,7 @@ function boot(
// terminal stack. Only the layout surface is under test here. // terminal stack. Only the layout surface is under test here.
const app = Object.create(win.__CodemanApp.prototype) as LayoutApp; const app = Object.create(win.__CodemanApp.prototype) as LayoutApp;
app.soloSessionId = options.solo ?? null; app.soloSessionId = options.solo ?? null;
app.isSoloWindow = !!app.soloSessionId;
app.sessions = new Map(); app.sessions = new Map();
app.sessionOrder = []; app.sessionOrder = [];
app._elemCache = new Map(); app._elemCache = new Map();
@@ -177,6 +181,39 @@ describe('session list layout', () => {
expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true); expect(toggleBtn(win).classList.contains('btn-sidebar-toggle--hidden')).toBe(true);
}); });
it('preserves vertical rail ownership when the session-list layout reapplies', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
app.applySessionListLayout();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
});
it('keeps aria orientation synchronized when tab orientation moves hosts', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
app.applySessionListLayout();
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('tabRail');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('vertical');
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'horizontal' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('re-parents the tab list into the sidebar and flips the a11y state', () => { it('re-parents the tab list into the sidebar and flips the a11y state', () => {
const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } }); const { win, app } = boot({ stored: { sessionListLayout: 'sidebar' } });
expect(app.getSessionListLayout()).toBe('sidebar'); expect(app.getSessionListLayout()).toBe('sidebar');
@@ -225,6 +262,20 @@ describe('session list layout', () => {
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost'); expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
}); });
it('forces horizontal tabs in a solo window even when vertical orientation is preferred', () => {
const { win, app } = boot({
stored: { sessionListLayout: 'header', tabOrientation: 'vertical' },
solo: 'sess-1',
});
app.applySessionListLayout();
app.applyTabOrientation();
expect(win.document.documentElement.dataset.tabOrientation).toBe('horizontal');
expect(tabsEl(win).parentElement?.id).toBe('sessionTabsHost');
expect(tabsEl(win).getAttribute('aria-orientation')).toBe('horizontal');
});
it('round-trips the collapse state through its own storage key', () => { it('round-trips the collapse state through its own storage key', () => {
// Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that // Deliberately NOT in the app-settings blob: saveAppSettings() rebuilds that
// blob from the DOM controls, so a key without a control is wiped on Save. // blob from the DOM controls, so a key without a control is wiped on Save.
@@ -0,0 +1,77 @@
/** Real-browser responsive layout coverage for Session Options. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
function sessionOptionsMarkup() {
const start = html.indexOf('<div class="modal" id="sessionOptionsModal">');
const end = html.indexOf('<!-- Close Session Confirmation Modal -->', start);
if (start < 0 || end < 0) throw new Error('Session Options markup not found');
return html.slice(start, end);
}
describe('Session Options responsive layout in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
});
afterAll(async () => browser.close());
async function renderAt(width: number) {
await page.setViewportSize({ width, height: 1000 });
await page.setContent(`<!doctype html><html><head><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${sessionOptionsMarkup()}</body></html>`);
await page.evaluate(() => {
document.getElementById('sessionOptionsModal')!.classList.add('active');
document
.querySelectorAll('#sessionOptionsModal .set-section')
.forEach((section) => section.classList.add('hidden'));
document.getElementById('context-tab')!.classList.remove('hidden');
});
}
async function metrics() {
return page.evaluate(() => {
const modal = document.querySelector<HTMLElement>('#sessionOptionsModal .modal-content')!;
const doc = document.getElementById('sessionOptionsDoc')!;
const panel = document.getElementById('context-tab')!;
const header = panel.querySelector<HTMLElement>(':scope > .set-section-head')!;
const blurb = panel.querySelector<HTMLElement>(':scope > .set-section-blurb')!;
return {
modalWidth: modal.getBoundingClientRect().width,
docFits: doc.scrollWidth === doc.clientWidth,
panelFits: panel.scrollWidth === panel.clientWidth,
tracks: getComputedStyle(panel).gridTemplateColumns.split(' '),
headerGridColumn: getComputedStyle(header).gridColumn,
blurbGridColumn: getComputedStyle(blurb).gridColumn,
};
});
}
it('uses one fitting column at the tablet-width desktop viewport', async () => {
await renderAt(974);
expect(await metrics()).toMatchObject({ docFits: true, panelFits: true, tracks: [expect.any(String)] });
});
it('uses two fitting columns with a full-width introduction on wide screens', async () => {
await renderAt(1440);
const layout = await metrics();
expect(layout.modalWidth).toBeGreaterThan(1000);
expect(layout.tracks).toHaveLength(2);
expect(layout.headerGridColumn).toBe('1 / -1');
expect(layout.blurbGridColumn).toBe('1 / -1');
expect(layout.docFits).toBe(true);
expect(layout.panelFits).toBe(true);
});
});
+9
View File
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
const publicDir = resolve(import.meta.dirname, '../src/web/public'); const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8'); const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
/** The Session Options markup, so assertions can't be satisfied elsewhere. */ /** The Session Options markup, so assertions can't be satisfied elsewhere. */
function optionsModal(): string { function optionsModal(): string {
@@ -94,4 +95,12 @@ describe('Session Options modal structure', () => {
expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {'); expect(css).toContain(':is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-row {');
expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {'); expect(css).toContain(':is(#sessionOptionsModal, #createCaseModal) .set-section.hidden {');
}); });
it('uses document-safe context columns and widens only at the desktop breakpoint', () => {
expect(styles).toMatch(/#sessionOptionsModal #context-tab\s*\{[^}]*minmax\(0, 1fr\)/s);
expect(styles).toMatch(
/@media \(min-width: 1200px\)[\s\S]*#sessionOptionsModal #context-tab[^}]*repeat\(2, minmax\(0, 1fr\)\)/
);
expect(styles).not.toMatch(/@media \(min-width: 680px\)[\s\S]{0,1200}#sessionOptionsModal #context-tab/);
});
}); });
+106
View File
@@ -0,0 +1,106 @@
/** Real Chromium visibility and click coverage for nested vertical session actions. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const styles = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
const controller = readFileSync(resolve(import.meta.dirname, '../src/web/public/tab-rail-resize.js'), 'utf8');
function fixture(surface: 'sidebar' | 'rail', active = false) {
const root =
surface === 'sidebar'
? 'data-session-list="sidebar" data-sidebar="expanded" data-tab-orientation="horizontal"'
: 'data-session-list="header" data-sidebar="expanded" data-tab-orientation="vertical"';
const hostClass = surface === 'sidebar' ? 'session-sidebar' : 'tab-rail';
return `<!doctype html><html ${root}><head><style>${styles}</style></head><body>
<div class="${hostClass}"><div class="session-tabs">
<div class="session-tab${active ? ' active' : ''}" tabindex="0">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name">session</span>
<span class="tab-actions"><button class="tab-more" type="button">…</button></span>
</span></span>
</div>
</div></div>
</body></html>`;
}
async function installActionMenuController(page: Page) {
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
app.loadAppSettingsFromStorage = () => ({ showTabDetachButton: false });
app.openSessionOptions = () => undefined;
app.requestCloseSession = () => undefined;
document
.querySelector('.tab-more')
?.addEventListener('click', (event) => app.openTabRailActionMenu(event, 'session-1'));
(window as any).app = app;
});
}
async function actionState(page: Page) {
return page.locator('.tab-more').evaluate((node) => {
const style = getComputedStyle(node);
return { visibility: style.visibility, pointerEvents: style.pointerEvents };
});
}
describe('expanded vertical session actions in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
});
afterAll(async () => {
await browser.close();
});
for (const surface of ['sidebar', 'rail'] as const) {
it(`${surface} hides inactive actions, reveals them contextually, and opens the menu`, async () => {
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.mouse.move(1200, 760);
const tab = page.locator('.session-tab');
expect(await actionState(page)).toEqual({ visibility: 'hidden', pointerEvents: 'none' });
await tab.hover();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.setContent(fixture(surface));
await installActionMenuController(page);
await page.locator('.session-tab').focus();
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
await page.setContent(fixture(surface, true));
await installActionMenuController(page);
expect(await actionState(page)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
});
}
it('keeps nested actions reachable for a coarse pointer', async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true });
try {
const touchPage = await context.newPage();
for (const surface of ['sidebar', 'rail'] as const) {
await touchPage.setContent(fixture(surface));
expect(await actionState(touchPage)).toEqual({ visibility: 'visible', pointerEvents: 'auto' });
}
} finally {
await context.close();
}
});
it('closes the real controller menu when viewport geometry changes', async () => {
await page.setContent(fixture('rail', true));
await installActionMenuController(page);
await page.locator('.tab-more').click();
expect(await page.locator('.tab-rail-action-menu').count()).toBe(1);
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
expect(await page.locator('.tab-rail-action-menu').count()).toBe(0);
});
});
+73
View File
@@ -0,0 +1,73 @@
/** Structural and schema coverage for vertical session navigation density and actions. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const app = readFileSync(resolve(publicDir, 'app.js'), 'utf8');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const i18n = readFileSync(resolve(publicDir, 'i18n.js'), 'utf8');
const railController = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('vertical session navigation UX contract', () => {
it('accepts only integer session-name sizes from 11 through 18 pixels', () => {
for (const value of [11, 14, 18]) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(true);
}
for (const value of [10, 19, 14.5, '14']) {
expect(SettingsUpdateSchema.safeParse({ sessionSidebarFontSize: value }).success).toBe(false);
}
});
it('renders one existing action cluster through a shared placement resolver', () => {
expect(app).toContain('shouldInlineSessionActions()');
expect(app).toContain('const inlineSessionActions = this.shouldInlineSessionActions();');
expect(app).toContain('const tabActionsHtml =');
expect(app).toContain("${inlineSessionActions ? tabActionsHtml : ''}");
expect(app).toContain("${inlineSessionActions ? '' : tabActionsHtml}");
expect(app.match(/class="tab-actions"/g)).toHaveLength(1);
expect(app).toContain("tab.querySelector(':scope > .tab-actions')");
});
it('limits inline actions to expanded sidebar and expanded non-compact rail', () => {
expect(app).toMatch(/isSessionSidebarActive\(\)[\s\S]{0,100}!this\.isSessionSidebarCollapsed\(\)/);
expect(app).toMatch(/_tabOrientation\(\) === 'vertical'[\s\S]{0,120}tab-rail-compact/);
expect(styles).toContain("html[data-session-list='sidebar'][data-sidebar='expanded']");
expect(styles).toContain("html[data-tab-orientation='vertical']:not(.tab-rail-compact)");
});
it('opens only the existing session actions from the overflow trigger', () => {
expect(railController).toContain('openTabRailActionMenu(event, sessionId)');
expect(railController).toContain("label: 'Session options'");
expect(railController).toContain("label: 'Open in a new window'");
expect(railController).toContain("label: 'Close session'");
expect(railController).not.toContain('Move to group');
});
it('wires the name-only size through first paint, settings, defaults, and both vertical surfaces', () => {
expect(html).toMatch(/id="appSettingsSessionSidebarFontSize"[^>]*min="11"[^>]*max="18"[^>]*step="1"/);
expect(html).toContain('aria-labelledby="appSettingsSessionSidebarFontSizeLabel"');
expect(html).toContain('--session-sidebar-name-font-size');
expect(settingsUi).toContain('sessionSidebarFontSize: this.resolveSessionSidebarFontSize(');
expect(settingsUi).toContain('sessionSidebarFontSize: 14,');
expect(settingsUi).toContain("'sessionSidebarFontSize'");
expect(app).toContain('resolveSessionSidebarFontSize(value)');
expect(app).toContain('applySessionSidebarFontSize(settings = null)');
expect(styles).toMatch(
/\.session-sidebar \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
);
expect(styles).toMatch(
/\.tab-rail \.session-tab \.tab-name[^}]*font-size: var\(--session-sidebar-name-font-size, 14px\)/s
);
});
it('labels and translates the name-only scope', () => {
expect(html).toContain('Session Name Font Size');
expect(html).toContain('Adjust only session names in the vertical sidebar.');
expect(i18n).toContain("'Session Name Font Size':");
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
});
});
+73
View File
@@ -0,0 +1,73 @@
/** @fileoverview COD-358 vertical session rail orientation policy and wiring. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const indexHtml = read('src/web/public/index.html');
const stylesCss = read('src/web/public/styles.css');
const settingsJs = read('src/web/public/settings-ui.js');
const appJs = read('src/web/public/app.js');
const schemasTs = read('src/web/schemas.ts');
function loadPolicy() {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(read('src/web/public/constants.js'), context, { filename: 'constants.js' });
return (
context.window as {
CodemanTabOverflow: { resolveTabOrientation: (input: unknown) => string };
}
).CodemanTabOverflow;
}
describe('vertical tab orientation policy', () => {
it('honors vertical on desktop and tablet but forces phones horizontal', () => {
const policy = loadPolicy();
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'vertical' })).toBe('vertical');
expect(policy.resolveTabOrientation({ deviceType: 'tablet', setting: 'vertical' })).toBe('vertical');
expect(policy.resolveTabOrientation({ deviceType: 'mobile', setting: 'vertical' })).toBe('horizontal');
});
it('fails closed to horizontal for absent and unknown values', () => {
const policy = loadPolicy();
expect(policy.resolveTabOrientation(undefined)).toBe('horizontal');
expect(policy.resolveTabOrientation({})).toBe('horizontal');
expect(policy.resolveTabOrientation({ deviceType: 'desktop', setting: 'sideways' })).toBe('horizontal');
});
});
describe('vertical tab rail wiring', () => {
it('ships one rail before the terminal and one orientation control', () => {
expect(indexHtml).toContain('id="tabRail"');
expect(indexHtml.indexOf('id="tabRail"')).toBeLessThan(indexHtml.indexOf('id="terminalContainer"'));
expect(indexHtml).toContain('id="appSettingsTabOrientation"');
expect(indexHtml).toContain('<option value="horizontal">');
expect(indexHtml).toContain('<option value="vertical">');
});
it('moves the existing list between the rail and its header host', () => {
expect(settingsJs).toMatch(/applyTabOrientation\(options = \{\}\)/);
expect(settingsJs).toContain('rail.appendChild(tabsEl)');
expect(settingsJs).toContain('headerHost.appendChild(tabsEl)');
expect(settingsJs).toContain("tabsEl.setAttribute('aria-orientation'");
});
it('applies orientation before horizontal wrapping and suppresses wrap vertically', () => {
expect(appJs.indexOf('this.applyTabOrientation()')).toBeLessThan(appJs.indexOf('this.applyTabWrapSettings()'));
const start = appJs.indexOf('updateTabOverflowMode() {');
const block = appJs.slice(start, start + 3500);
expect(block).toContain('resolveTabOrientation');
expect(block.indexOf('resolveTabOrientation')).toBeLessThan(block.indexOf('shouldAutoWrapTabs'));
});
it('round-trips a device-scoped strict setting and has vertical styles', () => {
expect(settingsJs).toMatch(/tabOrientation:\s*'horizontal'/);
expect(settingsJs).toMatch(/tabOrientation:\s*document\.getElementById\('appSettingsTabOrientation'\)\.value/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1800)).toContain("'tabOrientation'");
expect(schemasTs).toMatch(/tabOrientation:\s*z\.enum\(\['horizontal',\s*'vertical'\]\)\.optional\(\)/);
expect(stylesCss).toContain("html[data-tab-orientation='vertical'] .tab-rail");
});
});
+115
View File
@@ -0,0 +1,115 @@
/** @fileoverview Real Chromium coverage for COD-358 rail resize and label geometry. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
const controller = readFileSync(resolve(publicDir, 'tab-rail-resize.js'), 'utf8');
describe('COD-358 vertical rail in Chromium', () => {
let browser: Browser;
let page: Page;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.setContent(`<!doctype html><html data-tab-orientation="vertical"><head><style>${styles}</style></head>
<body><header class="header"><div id="sessionTabsHost"></div></header><main class="main" style="width:100%;height:700px">
<aside class="tab-rail" id="tabRail"><div class="session-tabs" id="sessionTabs"><div class="session-tab active">
<span class="tab-info"><span class="tab-name-row"><span class="tab-name"><span class="tab-name-prefix">w2-codeman: </span>super-long-unbroken-session-name-for-wrapping</span></span></span>
</div></div><div id="tabRailResizeHandle" class="tab-rail-resize-handle" role="separator" tabindex="0" aria-valuemin="208" aria-valuemax="360" aria-valuenow="256"></div></aside>
<div class="terminal-wrap"><iframe title="terminal"></iframe></div></main>
<div id="tabRailResizeShield" class="tab-rail-resize-shield" hidden></div>
<select id="appSettingsTabRailWidth"><option value="custom">Custom</option><option value="208">Compact</option><option value="256">Default</option><option value="320">Wide</option><option value="360">Maximum</option></select>
</body></html>`);
await page.addScriptTag({ content: constants });
await page.addScriptTag({ content: 'class CodemanApp {}; window.CodemanApp = CodemanApp;' });
await page.addScriptTag({ content: settingsUi });
await page.addScriptTag({ content: controller });
await page.evaluate(() => {
const app = new (window as any).CodemanApp();
const settings = { tabOrientation: 'vertical', tabRailWidth: 256 };
(window as any).__settings = settings;
(window as any).__deviceType = 'desktop';
(window as any).MobileDetection = { getDeviceType: () => (window as any).__deviceType };
app.activeSessionId = 'session-1';
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (next: Record<string, unknown>) => Object.assign(settings, next);
app.getDefaultSettings = () => ({});
app.isSessionSidebarActive = () => false;
app.updateTabOverflowMode = () => undefined;
app._fullRenderSessionTabs = () => undefined;
app._updateConnectionLinesImmediate = () => undefined;
app.fitAddon = { proposeDimensions: () => ({ cols: 80, rows: 24 }), fit: () => undefined };
app.sendResize = async () => ((window as any).__resizeCount = ((window as any).__resizeCount || 0) + 1);
app.initTabRailResize();
(window as any).app = app;
});
});
afterAll(async () => browser.close());
it('shows the complete label while clamping it to two lines', async () => {
const metrics = await page.locator('.tab-name').evaluate((element) => {
const style = getComputedStyle(element);
return {
text: element.textContent,
clamp: style.getPropertyValue('-webkit-line-clamp'),
whiteSpace: style.whiteSpace,
overflowWrap: style.overflowWrap,
height: element.getBoundingClientRect().height,
lineHeight: Number.parseFloat(style.lineHeight),
};
});
expect(metrics.text).toBe('w2-codeman: super-long-unbroken-session-name-for-wrapping');
expect(metrics.clamp).toBe('2');
expect(metrics.whiteSpace).toBe('normal');
expect(metrics.overflowWrap).toBe('anywhere');
expect(metrics.height).toBeLessThanOrEqual(metrics.lineHeight * 2 + 1);
});
it('resizes from the keyboard and settles once', async () => {
const handle = page.locator('#tabRailResizeHandle');
await handle.focus();
await handle.press('ArrowRight');
expect(await handle.getAttribute('aria-valuenow')).toBe('264');
await page.waitForTimeout(180);
expect(await page.evaluate(() => (window as any).__resizeCount)).toBe(1);
});
it('preserves 360px while a narrow viewport applies only an effective clamp', async () => {
await page.setViewportSize({ width: 700, height: 800 });
const narrow = await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
return { effective: (window as any).app.applyTabRailWidth(), preferred: (window as any).__settings.tabRailWidth };
});
expect(narrow).toEqual({ effective: 280, preferred: 360 });
await page.setViewportSize({ width: 1280, height: 800 });
expect(await page.evaluate(() => (window as any).app.applyTabRailWidth())).toBe(360);
});
it('reapplies phone fallback in both directions without changing preferences', async () => {
await page.evaluate(() => {
(window as any).__settings.tabRailWidth = 360;
(window as any).__deviceType = 'mobile';
});
await page.setViewportSize({ width: 390, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('sessionTabsHost');
expect(await page.evaluate(() => (window as any).__settings)).toEqual({
tabOrientation: 'vertical',
tabRailWidth: 360,
});
await page.evaluate(() => ((window as any).__deviceType = 'desktop'));
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(130);
expect(await page.locator('#sessionTabs').evaluate((node) => node.parentElement?.id)).toBe('tabRail');
expect(await page.evaluate(() => (window as any).app._getCurrentTabRailWidth())).toBe(360);
});
});
+226
View File
@@ -0,0 +1,226 @@
/** @fileoverview COD-358 resizable vertical session rail policy and wiring. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const readPublic = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
const indexHtml = readPublic('index.html');
const stylesCss = readPublic('styles.css');
const settingsJs = readPublic('settings-ui.js');
const appJs = readPublic('app.js');
const terminalUiJs = readPublic('terminal-ui.js');
const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8');
type RailPolicy = {
DEFAULT_WIDTH: number;
MIN_WIDTH: number;
MAX_WIDTH: number;
resolveWidth: (input?: Record<string, unknown>) => number;
resolveKeyboardWidth: (input: Record<string, unknown>) => number | null;
};
function loadRailPolicy(): RailPolicy {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanTabRail: RailPolicy }).CodemanTabRail;
}
afterEach(() => vi.useRealTimers());
describe('tab rail width policy', () => {
it('uses fixed storage bounds and separate viewport/terminal clamps', () => {
const policy = loadRailPolicy();
expect(policy.DEFAULT_WIDTH).toBe(256);
expect(policy.MIN_WIDTH).toBe(208);
expect(policy.MAX_WIDTH).toBe(360);
expect(policy.resolveWidth()).toBe(256);
expect(policy.resolveWidth({ width: 120 })).toBe(208);
expect(policy.resolveWidth({ width: 900 })).toBe(360);
expect(policy.resolveWidth({ width: 360, viewportWidth: 700 })).toBe(280);
expect(policy.resolveWidth({ width: 360 })).toBe(360);
expect(policy.resolveWidth({ width: 360, mainWidth: 700, minTerminalWidth: 400 })).toBe(300);
});
it('supports accessible keyboard steps and reset keys', () => {
const policy = loadRailPolicy();
const base = { currentWidth: 256, viewportWidth: 1600, mainWidth: 1200, minTerminalWidth: 400 };
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowLeft' })).toBe(248);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight' })).toBe(264);
expect(policy.resolveKeyboardWidth({ ...base, key: 'ArrowRight', shiftKey: true })).toBe(288);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
});
});
describe('tab rail resize wiring', () => {
it('ships an accessible handle and width presets', () => {
expect(indexHtml).toContain('id="tabRailResizeHandle"');
expect(indexHtml).toContain('role="separator"');
expect(indexHtml).toContain('aria-orientation="vertical"');
expect(indexHtml).toContain('aria-valuemin="208"');
expect(indexHtml).toContain('aria-valuemax="360"');
expect(indexHtml).toContain('id="appSettingsTabRailWidth"');
expect(indexHtml).toContain('<option value="208">Compact');
expect(indexHtml).toContain('<option value="256">Default');
expect(indexHtml).toContain('<option value="320">Wide');
expect(indexHtml).toContain('<option value="360">Maximum');
});
it('keeps preferred width device-local and distinct from effective width', () => {
expect(settingsJs).toMatch(/tabRailWidth:\s*256/);
expect(settingsJs).toMatch(/tabRailWidth:\s*this\.readTabRailWidthSetting/);
const displayKeys = settingsJs.slice(settingsJs.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1900)).toContain("'tabRailWidth'");
const controller = readPublic('tab-rail-resize.js');
expect(controller).toContain('_scheduleTabRailSettle(resolved, preferred)');
expect(controller).toContain('_scheduleTabRailSettle(effective, preferred)');
expect(controller).toContain('applyTabOrientation?.()');
expect(controller).toContain('applyTabRailWidth({ persist: false })');
});
it('installs the controller before terminal creation and owns generic observer churn', () => {
const controller = readPublic('tab-rail-resize.js');
expect(indexHtml).toContain('<script defer src="tab-rail-resize.js"></script>');
expect(appJs).toContain('this.initTabRailResize?.()');
expect(controller).toContain('setPointerCapture');
expect(controller).toContain("addEventListener('lostpointercapture'");
expect(controller).toContain('_tabRailResizeWatchdog');
expect(terminalUiJs).toMatch(/if \(this\._tabRailResizeOwnsObserver\) return/);
expect(buildJs).toContain("run('minify tab-rail-resize.js'");
expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1000}'tab-rail-resize\.js'/);
});
it('coalesces effective changes while persisting the preferred width', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: { querySelector: () => null },
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.activeSessionId = 'session-1';
app.sendResize = vi.fn(async () => true);
app._persistTabRailWidth = vi.fn();
app._tabRailResizeOwnsObserver = true;
app._scheduleTabRailSettle(280, 360);
app._scheduleTabRailSettle(300, 360);
await vi.advanceTimersByTimeAsync(150);
expect(app._persistTabRailWidth).toHaveBeenCalledOnce();
expect(app._persistTabRailWidth).toHaveBeenCalledWith(360);
expect(app.sendResize).toHaveBeenCalledOnce();
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
let resizing = true;
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: {},
document: {
body: { classList: { contains: (name: string) => name === 'tab-rail-resizing' && resizing } },
querySelector: () => null,
},
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app._claimTabRailResize();
await vi.advanceTimersByTimeAsync(1100);
expect(app._tabRailResizeOwnsObserver).toBe(true);
resizing = false;
await vi.advanceTimersByTimeAsync(1000);
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('keeps a viewport-clamped effective width out of the preferred-width control', () => {
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const values = new Map<string, string>();
const rootClasses = new Set<string>();
const custom = { textContent: 'Custom' };
const select = {
value: '360',
dataset: { currentWidth: '360' },
querySelector(selector: string) {
if (selector === 'option[value="360"]') return {};
if (selector === 'option[value="custom"]') return custom;
return null;
},
};
const handle = { setAttribute: vi.fn() };
const document = {
documentElement: {
style: {
getPropertyValue: (name: string) => values.get(name) ?? '',
setProperty: (name: string, value: string) => values.set(name, value),
},
classList: {
contains: (name: string) => rootClasses.has(name),
toggle: (name: string, force: boolean) => (force ? rootClasses.add(name) : rootClasses.delete(name)),
},
},
querySelector: (selector: string) => (selector === '.main' ? { clientWidth: 700 } : null),
getElementById: (id: string) => {
if (id === 'appSettingsTabRailWidth') return select;
if (id === 'tabRailResizeHandle') return handle;
return null;
},
};
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: { innerWidth: 700 },
document,
console,
clearTimeout,
setTimeout,
});
vm.runInContext(readPublic('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app.loadAppSettingsFromStorage = () => ({ tabRailWidth: 360 });
app.saveAppSettingsToStorage = vi.fn();
expect(app.applyTabRailWidth({ persist: false })).toBe(280);
expect(values.get('--tab-rail-width')).toBe('280px');
expect(select.value).toBe('360');
expect(select.dataset.currentWidth).toBe('360');
expect(app.readTabRailWidthSetting()).toBe(360);
});
it('retains complete labels and clamps only the settled vertical presentation', () => {
expect(appJs).toContain('class="tab-name-prefix"');
const start = stylesCss.indexOf("html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name");
const rule = stylesCss.slice(start, start + 800);
expect(rule).toContain('-webkit-line-clamp: 2');
expect(rule).toContain('overflow-wrap: anywhere');
expect(rule).toContain('white-space: normal');
});
it('closes detached rail menus before tab rebuilds and session cleanup', () => {
const fullRenderStart = appJs.indexOf(' _fullRenderSessionTabs() {');
const fullRender = appJs.slice(fullRenderStart, fullRenderStart + 250);
const cleanupStart = appJs.indexOf(' _cleanupSessionData(sessionId) {');
const cleanup = appJs.slice(cleanupStart, cleanupStart + 250);
expect(fullRender).toContain('this.closeTabRailActionMenu?.()');
expect(cleanup).toContain('this.closeTabRailActionMenu?.()');
});
});