diff --git a/src/web/public/app.js b/src/web/public/app.js index 6c8104ff..a61c9217 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -827,7 +827,7 @@ class CodemanApp { this.applyLocalization(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); - this._setupTabHoverSlide(); + this._setupTabMiddleClickClose(); // 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. @@ -3549,59 +3549,23 @@ 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() { + // Middle-click closes a tab, mirroring browser tab strips. Session tabs go + // through requestCloseSession (the same confirm modal as the x button), web + // tabs through closeWebviewTab (same as theirs). Delegated on the container: + // tabs are re-rendered wholesale, the container is stable. + _setupTabMiddleClickClose() { const container = this.$('sessionTabs'); - if (!container || this._tabHoverSlideBound) return; - this._tabHoverSlideBound = true; - const enter = (e) => { + if (!container || this._tabAuxClickBound) return; + this._tabAuxClickBound = true; + container.addEventListener('auxclick', (e) => { + if (e.button !== 1) return; 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); + if (!tab) return; + e.preventDefault(); + e.stopPropagation(); + if (tab.dataset.id) this.requestCloseSession(tab.dataset.id); + else if (tab.dataset.webviewId) this.closeWebviewTab?.(tab.dataset.webviewId); }); - 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() { @@ -3686,12 +3650,6 @@ 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/settings-ui.js b/src/web/public/settings-ui.js index d74025b1..8a66f097 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2003,7 +2003,7 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const defaults = this.getDefaultSettings(); - // Tab pop-out (open-in-new-window) button — opt-in (App Settings → Tab Bar, + // 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). diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6d99eecf..10c9a7cf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -1421,16 +1421,14 @@ 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; } -/* 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; - } +/* Icons expand on the ACTIVE tab only (in flow): selection is a deliberate + click, so the width change never happens while aiming at a tab, background + tabs keep their full title on hover, and a stray click can only switch. + Middle-click closes any tab (_setupTabMiddleClickClose in app.js). */ +.session-tab.active .tab-close { + opacity: 1; + width: auto; + padding: 0.15rem 0.35rem; } .session-tab .tab-close:hover { @@ -1989,12 +1987,10 @@ html[data-line-anim="packet"] .connection-line.line-enter { transition: opacity 0.15s, width 0.15s, padding 0.15s, transform 0.2s; } -@media (hover: none) { - .session-tab:hover .tab-gear { - opacity: 1; - width: auto; - padding: 0 0.3rem; - } +.session-tab.active .tab-gear { + opacity: 1; + width: auto; + padding: 0 0.3rem; } .session-tab .tab-gear:hover { @@ -2016,12 +2012,10 @@ html[data-line-anim="packet"] .connection-line.line-enter { overflow: hidden; transition: opacity 0.15s, width 0.15s, padding 0.15s; } -@media (hover: none) { - .session-tab:hover .tab-detach { - opacity: 1; - width: auto; - padding: 0 0.3rem; - } +.session-tab.active .tab-detach { + opacity: 1; + width: auto; + padding: 0 0.3rem; } .session-tab .tab-detach:hover { color: var(--accent-hover); @@ -2057,16 +2051,15 @@ 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. */ +/* ===== Tab action icons: active tab only ================================= + All three per-tab icons live in a .tab-actions wrapper (in flow; it adds + no width of its own while the children keep the width:0 collapse above). + They expand only on the ACTIVE tab: selection is a deliberate click, so + the tab-strip geometry never shifts while the pointer is aiming, hovering + a background tab changes nothing (full title stays readable), and a stray + click can only switch sessions. Middle-click closes any tab. The phone + layout in mobile.css follows the same active-only pattern with its own + sizing; the tablet touch fallback there keeps icons always visible. */ .session-tab .tab-actions { display: flex; @@ -2075,78 +2068,12 @@ html[data-line-anim="packet"] .connection-line.line-enter { /* 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 + 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,