diff --git a/src/web/public/app.js b/src/web/public/app.js index c94248bf..6c8104ff 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -827,6 +827,7 @@ class CodemanApp { this.applyLocalization(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); + this._setupTabHoverSlide(); // Must run before the first session:created can arrive: markSessionTabEntering() // ignores ids until this sets up its state, which is what keeps the tabs // restored on page load from animating. @@ -3489,11 +3490,12 @@ class CodemanApp { } } } else if (minimizedCount > 0 && !subagentBadgeEl) { - // Need to add badge - insert before gear icon + // 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 gearEl = tab.querySelector('.tab-gear'); - if (gearEl) { - gearEl.insertAdjacentHTML('beforebegin', badgeHtml); + const actionsEl = tab.querySelector('.tab-actions'); + if (actionsEl) { + actionsEl.insertAdjacentHTML('beforebegin', badgeHtml); } } else if (minimizedCount === 0 && subagentBadgeEl) { // Count went to 0 - remove badge @@ -3547,6 +3549,61 @@ class CodemanApp { container.classList.toggle('tabs-auto-wrap', shouldWrap); } + // Tab hover reveal (hover-capable devices): the tab keeps its width — the + // .tab-actions overlay slides in over the right edge while the title (and any + // badges) slide left just far enough to clear it, clipped at the LEFT edge of + // .tab-info so the name's tail stays readable. CSS owns the reveal itself + // (:hover/:focus-within in styles.css); JS only computes the per-tab slide + // distance (--tab-slide), because text width is unknowable in CSS and short + // names should barely move. + _setupTabHoverSlide() { + const container = this.$('sessionTabs'); + if (!container || this._tabHoverSlideBound) return; + this._tabHoverSlideBound = true; + const enter = (e) => { + const tab = e.target.closest?.('.session-tab'); + if (!tab || !container.contains(tab)) return; + // Moving within the tab: skip unless the slide was cleared mid-hover + // (focusout can clear it while the pointer never left). + if (e.relatedTarget && tab.contains(e.relatedTarget) && tab.style.getPropertyValue('--tab-slide')) return; + this._applyTabHoverSlide(tab); + }; + const leave = (e) => { + const tab = e.target.closest?.('.session-tab'); + if (!tab || (e.relatedTarget && tab.contains(e.relatedTarget))) return; + if (e.type === 'focusout' && tab.matches(':hover')) return; // pointer still owns the reveal + tab.style.removeProperty('--tab-slide'); + }; + container.addEventListener('pointerover', enter); + container.addEventListener('pointerout', leave); + // Keyboard path: tabbing onto an icon reveals the overlay via + // .tab-actions:has(:focus-visible). Clicking a tab also focuses it + // (tabindex=0), so only focus INSIDE the actions wrapper may trigger the + // slide — else every click would slide the title with no icons showing. + container.addEventListener('focusin', (e) => { + if (e.target.closest?.('.tab-actions')) enter(e); + }); + container.addEventListener('focusout', leave); + } + + _applyTabHoverSlide(tab) { + const actions = tab.querySelector('.tab-actions'); + // Zero width = overlay inactive (touch layout keeps icons in-flow) or no icons. + if (!actions || !actions.offsetWidth) return; + // offsetLeft/offsetWidth are pre-transform layout values, and offsetParent is + // the tab itself (position: relative), so both sides share the same origin. + let contentRight = 0; + for (const child of tab.children) { + if (child === actions) continue; + const cl = child.classList; + if (cl.contains('tab-number') || cl.contains('tab-status') || cl.contains('tab-load-spinner') || cl.contains('tab-web-icon')) continue; + contentRight = Math.max(contentRight, child.offsetLeft + child.offsetWidth); + } + const shift = Math.min(Math.max(0, contentRight + 4 - actions.offsetLeft), actions.offsetWidth + 12); + if (shift > 0) tab.style.setProperty('--tab-slide', shift + 'px'); + else tab.style.removeProperty('--tab-slide'); + } + _fullRenderSessionTabs() { if (this._inlineRenameActive) return; const container = this.$('sessionTabs'); @@ -3611,9 +3668,7 @@ class CodemanApp { ${hasRunningTasks ? `${taskStats.running}` : ''} ${subagentBadge} ${ultracodeBadge} - ⚙ - ⧉ - × + ⚙⧉× `); _tabIdx++; } @@ -3631,6 +3686,12 @@ class CodemanApp { // Set up keyboard navigation for tabs this.setupTabKeyboardNavigation(container); + // A rebuild replaces a hovered tab mid-hover (SSE-driven renders): the fresh + // element matches :hover immediately but its inline --tab-slide is gone, so + // re-derive it or the title stops clearing the action icons. + const hoveredTab = container.querySelector('.session-tab:hover'); + if (hoveredTab) this._applyTabHoverSlide(hoveredTab); + // Update connection lines after tabs change (positions may have shifted) this.updateConnectionLines(); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 40caffed..b94c5806 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -227,6 +227,7 @@ 'Redraw Terminal Button': '重绘终端按钮', 'Tab Bar': '标签栏', 'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)', + 'Pop-out Button on Tabs': '标签页弹出窗口按钮', Panels: '面板', Monitor: '监视器', 'Project Insights': '项目洞察', diff --git a/src/web/public/index.html b/src/web/public/index.html index d7d53f51..1124ed99 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1480,6 +1480,13 @@ +
+ Pop-out Button on Tabs + +
Panels
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 217229f4..d74025b1 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -363,6 +363,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; + document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const allowedToolsRow = document.getElementById('allowedToolsRow'); @@ -1542,6 +1543,7 @@ Object.assign(CodemanApp.prototype, { webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, + showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked, skin: document.getElementById('appSettingsSkin').value, // Claude CLI settings claudeMode: document.getElementById('appSettingsClaudeMode').value, @@ -1726,6 +1728,7 @@ Object.assign(CodemanApp.prototype, { showSessionButton: _ssb, showAwayDigestButton: _adb, showCronButton: _crb, + showTabDetachButton: _tdb, // Phone-only home surface, and absent from SettingsUpdateSchema (.strict()). mobileOverviewEnabled: _mov, ...serverSettings @@ -1999,6 +2002,13 @@ Object.assign(CodemanApp.prototype, { applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); + + // Tab pop-out (open-in-new-window) button — opt-in (App Settings → Tab Bar, + // default OFF, per-device). Mirrored as a class on : styles.css hides + // .tab-detach without it (a tab that is already detached keeps its icon as + // the re-focus affordance for the popped-out window). + const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; + document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true); @@ -2355,6 +2365,7 @@ Object.assign(CodemanApp.prototype, { 'language', 'terminalWheelLocalScrollback', 'showSessionButton', 'showAwayDigestButton', 'showCronButton', + 'showTabDetachButton', 'mobileOverviewEnabled', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bd2a2401..6d99eecf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1421,10 +1421,16 @@ html[data-line-anim="packet"] .connection-line.line-enter { transition: opacity 0.05s ease-out, width 0.05s ease-out, padding 0.05s ease-out; } -.session-tab:hover .tab-close { - opacity: 1; - width: auto; - padding: 0.15rem 0.35rem; +/* Touch-only devices wider than mobile.css's breakpoint (e.g. iPad Pro + landscape) keep the legacy tap-reveal: tap sets sticky :hover, icons gain + width, the tab grows. Hover-capable devices instead use the fixed-width + .tab-actions overlay defined after the detach section. */ +@media (hover: none) { + .session-tab:hover .tab-close { + opacity: 1; + width: auto; + padding: 0.15rem 0.35rem; + } } .session-tab .tab-close:hover { @@ -1983,10 +1989,12 @@ html[data-line-anim="packet"] .connection-line.line-enter { transition: opacity 0.15s, width 0.15s, padding 0.15s, transform 0.2s; } -.session-tab:hover .tab-gear { - opacity: 1; - width: auto; - padding: 0 0.3rem; +@media (hover: none) { + .session-tab:hover .tab-gear { + opacity: 1; + width: auto; + padding: 0 0.3rem; + } } .session-tab .tab-gear:hover { @@ -2008,10 +2016,12 @@ html[data-line-anim="packet"] .connection-line.line-enter { overflow: hidden; transition: opacity 0.15s, width 0.15s, padding 0.15s; } -.session-tab:hover .tab-detach { - opacity: 1; - width: auto; - padding: 0 0.3rem; +@media (hover: none) { + .session-tab:hover .tab-detach { + opacity: 1; + width: auto; + padding: 0 0.3rem; + } } .session-tab .tab-detach:hover { color: var(--accent-hover); @@ -2047,6 +2057,96 @@ html[data-line-anim="packet"] .connection-line.line-enter { display: inline-flex; } +/* ===== Fixed-width tab hover: icons overlay, title slides ================ + All three per-tab icons live in a .tab-actions wrapper. In flow it adds no + width (children keep the width:0 collapse above), so the touch paths in + mobile.css behave exactly as before. On hover-capable devices the wrapper + is lifted out of flow instead: the tab NEVER changes width on hover — the + icons slide in over the right edge while the title row (and any badges) + slide left by --tab-slide, set per-tab by _applyTabHoverSlide() in app.js + (text width is unknowable in CSS; short names should barely move). The + sliding title clips at the LEFT edge of .tab-info (overflow: hidden), so + the readable tail — the :comment suffix — stays visible. */ + +.session-tab .tab-actions { + display: flex; + align-items: center; +} + +/* 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 . + A tab that is ALREADY detached keeps its icon regardless — it is the + re-focus affordance for the popped-out window. */ +html:not(.tabs-show-detach) .session-tab:not(.detached) .tab-detach { + display: none; +} + +@media (hover: hover) { + .session-tab .tab-actions { + position: absolute; + top: 50%; + right: 3px; + padding: 0 1px; + opacity: 0; + pointer-events: none; + transform: translateY(-50%) translateX(10px); + transition: transform 0.16s ease-out, opacity 0.16s ease-out; + } + /* Reveal on hover, or on KEYBOARD focus of an icon (:focus-visible, so a + mouse click on the gear does not pin the overlay open after the pointer + leaves — spans with tabindex keep focus after a click). */ + .session-tab:hover .tab-actions, + .session-tab .tab-actions:has(:focus-visible) { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) translateX(0); + } + /* Un-collapse the icons: the wrapper overlays instead of growing the tab. */ + .session-tab .tab-actions .tab-gear, + .session-tab .tab-actions .tab-detach, + .session-tab .tab-actions .tab-close { + opacity: 1; + width: auto; + overflow: visible; + padding: 0.1rem 0.25rem; + } + /* Title and badges slide left to clear the incoming icons. Badges keep + their own (shorter) transition so background hovers stay intact, and + their scale-on-hover must COMPOSE with the slide (transform is a single + property — a bare scale() would snap the badge back right). */ + .session-tab .tab-name-row, + .session-tab .tab-folder { + transform: translateX(calc(-1 * var(--tab-slide, 0px))); + transition: transform 0.16s ease-out; + } + .session-tab > .tab-badge { + transition: transform 0.16s ease-out; + } + .session-tab > .tab-badge, + .session-tab > .tab-subagent-badge, + .session-tab > .tab-ultracode-badge { + transform: translateX(calc(-1 * var(--tab-slide, 0px))); + } + .session-tab > .tab-subagent-badge:hover, + .session-tab > .tab-ultracode-badge:hover { + transform: translateX(calc(-1 * var(--tab-slide, 0px))) scale(1.05); + } + /* Detached tab: the pop-out icon stays visible at rest as the re-focus + affordance; reserve its footprint so it never covers the title. */ + .session-tab.detached { + padding-right: 26px; + } + .session-tab.detached .tab-actions { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%); + } + .session-tab.detached:not(:hover) .tab-actions:not(:has(:focus-visible)) .tab-gear, + .session-tab.detached:not(:hover) .tab-actions:not(:has(:focus-visible)) .tab-close { + display: none; + } +} + /* ===== Solo (detached single-session) window chrome ===================== */ body.solo-mode .session-tabs, body.solo-mode .header-system-stats, diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 883662a5..0962d469 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -97,8 +97,7 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(webview.name)} - ⚙ - × + ⚙× `); idx++; }