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++;
}