feat: tab action icons on the active tab only, middle-click closes tabs

Rework of the previous hover-overlay approach after feedback: sliding the
title under incoming icons made names hard to read, and icons appearing
under the cursor caused accidental gear/close clicks while switching tabs.

Now the gear/pop-out/close icons expand in flow on the ACTIVE tab only.
Selection is a deliberate click, so the strip's geometry never changes
while the pointer is aiming at a tab; hovering a background tab changes
nothing (the full title stays readable) and a stray click can only switch
sessions. Middle-click closes any tab (session tabs via the existing
close-confirm modal, web tabs via closeWebviewTab), matching browser
muscle memory so background tabs still close in one action.

The pop-out button stays opt-in via App Settings -> Tab Bar (per-device
showTabDetachButton, default off), and a detached tab keeps its icon as
the re-focus affordance. Phone layouts already used the active-only
pattern; tablets keep their always-visible touch fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-08 13:58:10 +02:00
parent a9f26bd03a
commit fa18eeef35
3 changed files with 42 additions and 157 deletions
+15 -57
View File
@@ -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();