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();
+1 -1
View File
@@ -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 <html>: 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).
+26 -99
View File
@@ -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 <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
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,