mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(tabs): COD-358 add resizable vertical session rail
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename.
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">⋯</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">⚙</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">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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': '左侧边栏(简洁)',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.':");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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?.()');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user