mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat: fixed-width tab hover with sliding title, pop-out button now opt-in
Hovering a session tab no longer grows it. The three per-tab icons now live in a .tab-actions wrapper that overlays the tab's right edge on hover-capable devices: the icons slide in while the title (and any badges) slide left by a per-tab --tab-slide distance computed in _applyTabHoverSlide(), clipped at the left edge of .tab-info so the readable tail (the :comment suffix) stays visible. Keyboard focus reveals the overlay via :has(:focus-visible), so a mouse click on the gear does not pin it open. Touch devices keep the previous in-flow behavior (the wrapper adds no width in flow, and the legacy tap-reveal rules are preserved under @media (hover: none)). The open-in-a-new-window (pop-out) button is now hidden by default and opt-in via App Settings -> Tab Bar -> "Pop-out Button on Tabs" (showTabDetachButton, per-device, absent from SettingsUpdateSchema like the other display keys). A tab whose session is already detached keeps its icon as the re-focus affordance regardless of the setting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+68
-7
@@ -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 ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
|
||||
${subagentBadge}
|
||||
${ultracodeBadge}
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span>
|
||||
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span>
|
||||
<span class="tab-actions"><span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">⚙</span><span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">⧉</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">×</span></span>
|
||||
</div>`);
|
||||
_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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user