diff --git a/.changeset/cod-358-upstream-vertical-rail.md b/.changeset/cod-358-upstream-vertical-rail.md new file mode 100644 index 00000000..180ec6a5 --- /dev/null +++ b/.changeset/cod-358-upstream-vertical-rail.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Add an optional resizable vertical session rail with responsive layout, complete labels, accessible controls, and stable inline rename. diff --git a/config/test-suites.ts b/config/test-suites.ts index a1effdc7..cef400cc 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -19,6 +19,9 @@ * why these are a runnable suite (`npm run test:browser`) rather than skipped. */ 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/opencode-resize.test.ts', 'test/webgl-fallback.test.ts', diff --git a/scripts/build.mjs b/scripts/build.mjs index eac90a18..d7236f5f 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -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 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 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 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'); @@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting'); 'input-cjk.js', 'sanitize-html.js', 'app.js', + 'tab-rail-resize.js', 'terminal-ui.js', 'respawn-ui.js', 'ralph-panel.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 1c78ee62..065835c2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -919,6 +919,8 @@ class CodemanApp { // Calls applyTabWrapSettings() itself (it owns tabs-two-rows / tabs-show-folder) // and then applies the sidebar variant on top — do not call both. this.applySessionListLayout(); + this.applyTabOrientation(); + this.initTabRailResize?.(); this.applyMonitorVisibility(); this.applyLineageLineSettings?.(); this._installLineageStripScrollListener?.(); @@ -3756,6 +3758,18 @@ class CodemanApp { 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 , not the settings blob: this is called * per dragover event and per tab in render loops, and getSessionListLayout() @@ -3767,6 +3781,15 @@ class CodemanApp { 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 * per-session line ("created 3d ago · working 12m") plus a status pill. @@ -3859,17 +3882,22 @@ class CodemanApp { */ applySessionListLayout() { const mode = this.getSessionListLayout(); + this.applySessionSidebarFontSize(); // 'sidebar' and 'sidebar-rich' are the same column; only row detail differs. const sidebar = mode === 'sidebar' || mode === 'sidebar-rich'; const collapsed = this.isSessionSidebarCollapsed(); const prevMode = document.documentElement.dataset.sessionList; const prevDetail = document.documentElement.dataset.sidebarDetail; + const prevCollapsed = document.documentElement.dataset.sidebar; const tabsEl = document.getElementById('sessionTabs'); const headerHost = document.getElementById('sessionTabsHost'); const sidebarList = document.getElementById('sessionSidebarList'); 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); 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. document.documentElement.dataset.sidebarDetail = mode === 'sidebar-rich' ? 'rich' : 'simple'; 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'); if (btn) { @@ -3931,7 +3959,8 @@ class CodemanApp { const layoutChanged = prevMode !== document.documentElement.dataset.sessionList || 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(); } // tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode() @@ -4474,9 +4503,17 @@ class CodemanApp { const nameEl = tab.querySelector('.tab-name'); if (nameEl) { const _p = parseSessionPrefix(name); - const _label = _p && _p.suffix ? _p.suffix : name; - if (nameEl.textContent !== _label) { - nameEl.textContent = _label; + if (nameEl.dataset.fullName !== name) { + nameEl.replaceChildren(); + 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 ? (session.workingDir ? `${_p.prefix} (${session.workingDir})` : _p.prefix) : (session.workingDir || ''); @@ -4527,9 +4564,11 @@ class CodemanApp { // Need to add badge - insert before the action-icon overlay so the // badge stays a direct child of the tab (outside .tab-actions) const badgeHtml = this.renderSubagentTabBadge(id, minimizedAgents); - const actionsEl = tab.querySelector('.tab-actions'); + const actionsEl = tab.querySelector(':scope > .tab-actions'); if (actionsEl) { actionsEl.insertAdjacentHTML('beforebegin', badgeHtml); + } else { + tab.insertAdjacentHTML('beforeend', badgeHtml); } } else if (minimizedCount === 0 && subagentBadgeEl) { // Count went to 0 - remove badge @@ -4587,6 +4626,17 @@ class CodemanApp { const defaults = this.getDefaultSettings(); 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') { container.classList.remove('tabs-auto-wrap'); return; @@ -4627,6 +4677,7 @@ class CodemanApp { } _fullRenderSessionTabs() { + this.closeTabRailActionMenu?.(); if (this._inlineRenameActive) return; const container = this.$('sessionTabs'); @@ -4704,7 +4755,9 @@ class CodemanApp { // JUST the description on the tab; the generated w- id moves to the // tooltip and stays visible in the session settings modal. const parsedName = parseSessionPrefix(name); - const tabLabel = parsedName && parsedName.suffix ? parsedName.suffix : name; + const tabLabel = parsedName && parsedName.suffix + ? `${escapeHtml(parsedName.prefix)}: ${escapeHtml(parsedName.suffix)}` + : escapeHtml(name); const tabTooltip = parsedName && parsedName.suffix ? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix) : (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}"` : ''; + const inlineSessionActions = this.shouldInlineSessionActions(); + const tabActionsHtml = `⚙⧉×`; + parts.push(``); _tabIdx++; } @@ -6025,6 +6082,7 @@ class CodemanApp { // Shared cleanup for all session data — called from both closeSession() and session:deleted handler _cleanupSessionData(sessionId) { + this.closeTabRailActionMenu?.(); // If the deleted session is currently being renamed, abort the rename // so the inline doesn't ghost as a stale tab on screen. if (this._activeRename?.sessionId === sessionId) { diff --git a/src/web/public/constants.js b/src/web/public/constants.js index d9d550d9..8fcc4c4b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -156,6 +156,43 @@ function shouldAutoWrapTabs(input) { 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 // 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. @@ -243,6 +280,9 @@ const LINEAGE_DIP_MAX_PX = 64; // apart bled into one thick band instead of reading as three separate lines. const LINEAGE_SIBLING_STEP_PX = 8; 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 // (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. @@ -265,21 +305,44 @@ function computeLineagePath(input) { const ch = Number(child.height) || 0; if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null; - const px = Number(parent.left) + pw / 2; - const cx = Number(child.left) + cw / 2; - if (!Number.isFinite(px) || !Number.isFinite(cx)) return null; - + const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal'; 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) { const min = Number(strip.left) - 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; } - const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0)); - const pTop = Number(parent.top); const pBottom = pTop + ph; - const cTop = Number(child.top); const cBottom = cTop + ch; 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.CodemanTabOverflow = { shouldAutoWrapTabs, + resolveTabOrientation, computeTabScrollLeft, 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 = { plan: planWsReconnect, }; @@ -628,6 +699,8 @@ if (typeof window !== 'undefined') { DIP_MIN_PX: LINEAGE_DIP_MIN_PX, DIP_MAX_PX: LINEAGE_DIP_MAX_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, }; window.CodemanConnectionLoss = { diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 7bd8d676..5b06a7b8 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -87,12 +87,14 @@ Object.assign(CodemanApp.prototype, { /** * 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 - * lists of the same thing on one screen is worse than none. + * to the phone overview and persistent vertical tab rail: those surfaces already + * list the same sessions, and two lists of the same thing on one screen is worse + * than none. */ shouldShowHomeSessions() { if (this.isSoloWindow) 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 // 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). diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index dcebdca7..d8166098 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -232,6 +232,8 @@ 'Redraw Terminal Button': '重绘终端按钮', 'Tab Bar': '标签栏', 'Session List Layout': '会话列表布局', + 'Session Name Font Size': '会话名称字体大小', + 'Adjust only session names in the vertical sidebar.': '仅调整垂直侧边栏中的会话名称。', 'Header tab strip': '顶栏标签条', 'Left sidebar': '左侧边栏', 'Left sidebar simple': '左侧边栏(简洁)', diff --git a/src/web/public/index.html b/src/web/public/index.html index c5efd91b..62d8b2d0 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -65,7 +65,7 @@ app.js, NOT the handheld storage-key test `m`. Use a different predicate here and boot will contradict this value, animating the drawer open by itself on every load between 768 and 1023px. --> - + + + ${sessionOptionsMarkup()}`); + 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('#sessionOptionsModal .modal-content')!; + const doc = document.getElementById('sessionOptionsDoc')!; + const panel = document.getElementById('context-tab')!; + const header = panel.querySelector(':scope > .set-section-head')!; + const blurb = panel.querySelector(':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); + }); +}); diff --git a/test/session-options-structure.test.ts b/test/session-options-structure.test.ts index f2cf715b..4df59cdf 100644 --- a/test/session-options-structure.test.ts +++ b/test/session-options-structure.test.ts @@ -16,6 +16,7 @@ import { resolve } from 'node:path'; const publicDir = resolve(import.meta.dirname, '../src/web/public'); const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); const sessionUi = readFileSync(resolve(publicDir, 'session-ui.js'), 'utf8'); +const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); /** The Session Options markup, so assertions can't be satisfied elsewhere. */ 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(#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/); + }); }); diff --git a/test/session-sidebar-ux.browser.test.ts b/test/session-sidebar-ux.browser.test.ts new file mode 100644 index 00000000..2ac3f0ff --- /dev/null +++ b/test/session-sidebar-ux.browser.test.ts @@ -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 ` +
+
+ session + + +
+
+ `; +} + +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); + }); +}); diff --git a/test/session-sidebar-ux.test.ts b/test/session-sidebar-ux.test.ts new file mode 100644 index 00000000..3bca17cf --- /dev/null +++ b/test/session-sidebar-ux.test.ts @@ -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.':"); + }); +}); diff --git a/test/tab-orientation.test.ts b/test/tab-orientation.test.ts new file mode 100644 index 00000000..7ac05234 --- /dev/null +++ b/test/tab-orientation.test.ts @@ -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('