feat(tabs): start a session beside any tab from an opt-in + in its actions

Opening another session in the case you are already working in meant re-picking the case and the mode in the toolbar. An opt-in + now sits with each tab's gear and close icons (App Settings, Tabs, New Session Button on Tabs: per device, off by default everywhere). It opens a menu of the CLIs that tab's case can start, each shown with its logo and name as in the Run menus, and a pick starts one session in that tab's case, or the managed worktree it runs in, and in its named group, without changing the Run button's mode or case.

The CLIs offered follow the Run menu's own rule for the case, so a Docker case lists what its container has rather than what the host has. A tab outside any named group sends no group, so the case's default session group places the new session the way it places a toolbar Run. Wherever a tab has a row menu (the vertical rail, the sidebar) that menu gains "New session here…", the keyboard route to the same picker, since Enter on the + itself does nothing. On a phone the + joins gear and close on the active tab only, and the label reserve grows to 68px while the setting is on, so the tab's centre stays off the icons.

The shared tab layout menu no longer closes when focus leaves an item for nowhere: Safari never focuses a clicked button, so pressing an item blurred the focused one with no related target and the click was lost. This is the tile count menu's rule, and it fixes the named-group menu as well.
This commit is contained in:
JD committed 2026-10-24 20:29:00 -04:00
1 parent 3e06f3c141
commit c7835a159c
18 files changed
+1013 -45

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on `<html>`; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](../../docs/architecture-invariants.md#entrance-animations)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. With the opt-in tab "+" on (`showTabNewButton`, `html.tabs-show-new`) a third icon joins them and the reserve grows to 68px, still under the 80px `max-width`. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](../../docs/architecture-invariants.md#mobile-tab-strip-scrolling)
**Session list layout: header strip or left sidebar** (`sessionListLayout`, default `header`; per-device via `displayKeys`, also in `SettingsUpdateSchema`): the list can move into a collapsible `<aside>` (Alt+B, `toggleSessionSidebar`) or, via `tabOrientation`, a resizable vertical `#tabRail` (desktop/tablet only). ⚠️ There is ONE `#sessionTabs`, MOVED between hosts, never a second list: `applySessionListLayout()` runs first, then `applyTabOrientation()`, both BEFORE `applyTabWrapSettings()`, and both arm/disarm `_startSidebarRichClock()`. ⚠️ Axis decisions use `_isVerticalTabList()`, never `isSessionSidebarActive()` alone. ⚠️ Rich rows share one gate, `isRichTabRows()`; rich CSS pairs sidebar+rail with comma-grouped selectors, never `:is()`, and card rules stay rail-scoped. ⚠️ Rail sort (`tabRailSort`, default `activity`) is the flex `order` property only, never a DOM reorder; the arrow-key walk and the phone swipe follow computed `order`. ⚠️ Leaving sidebar mode clears `_sidebarFilter`; the handheld overlay drawer is `inert` when closed, the docked rail never. ⚠️ The rail's **Search sessions** box (`setTabRailSearch`, in memory only) and the sidebar filter share ONE row filter, `_applyTabListFilter()` over the pure `CodemanTabSearch` (constants.js): classes only, never a reorder or layout write; the rail matches the name, the sidebar name + folder. ⚠️ A session row with a tab alert (`tabAlerts`, red or yellow) is never hidden by either box: it goes in as `keep: true`, counts toward its group (which stays on screen) but not toward `matchCount`. ⚠️ Escape in the rail box is claimed by the global capture-phase Escape branch (the box's own `onkeydown` runs after it, too late to stop `closeAllPanels()`), and a filter change that shows or hides anything redraws the connector lines, since a keystroke re-renders nothing; a floating window finds its parent tab only through `_paintedSessionTab()` (a hidden row's rect is all zero but truthy, which drew connectors from the viewport's corner). While it runs `_projectTabGroups()` projects every group open (a collapsed group's rows are not in the DOM) and `toggleTabGroupCollapsed()` refuses, leaving the stored collapse untouched; drag stays on during a search (owner decision), a drop landing between the visible neighbours; `applyTabOrientation()` clears it off the rail. Tests: `test/tab-rail-search.test.ts`, `test/tab-rail-search.browser.test.ts`. → [architecture-invariants#session-list-layout-header-strip-vs-left-sidebar](../../docs/architecture-invariants.md#session-list-layout-header-strip-vs-left-sidebar)
+108 -11
View File
@@ -7141,7 +7141,7 @@ class CodemanApp {
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
const inlineSessionActions = this.shouldInlineSessionActions();
const tabActionsHtml = `<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">&#x2699;</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">&#x29C9;</span><span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
const tabActionsHtml = `<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">&#x2699;</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">&#x29C9;</span><span class="tab-new" onclick="event.stopPropagation(); app.openTabNewSessionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="New session here" aria-label="New session in this tab's case" aria-haspopup="menu" 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">&times;</span><button type="button" class="tab-more" onclick="event.stopPropagation(); app.openTabRailActionMenu(event, ${escapeHtml(JSON.stringify(id))})" title="Session actions" aria-label="Session actions">&#x22EF;</button></span>`;
rowHtml.set(id, `<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${richClass}${paneExitBadge ? ' tab-agent-exited' : ''}${loadState ? ' tab-loading' : ''}${this.hasTabDetachOverride(id) ? ' tab-show-detach' : ''}${this._tileGrid?.has(id) ? ' in-tiles' : ''}"${richData}${railOrderStyle} data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" ${tabTooltip ? `title="${escapeHtml(tabTooltip)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
@@ -8370,12 +8370,95 @@ class CodemanApp {
return true;
}
/**
* A tab's "+" (and the row menu's "New session here…"): the CLIs that tab's
* case can start, each as its logo and name like the Run menus. A pick starts
* ONE session beside that tab: its case (or the managed worktree it runs in)
* and its named group, if it has one, through `_launchOnce`, so the Run
* button's mode and case are untouched.
*/
openTabNewSessionMenu(event, sessionId) {
const session = this.sessions.get(sessionId);
const worktree = this._managedWorktreeOfSession?.(session) || null;
const caseEntry = worktree ? null : this._tabCaseOf(session);
if (!worktree && !caseEntry) {
event?.stopPropagation?.();
this.showToast?.("This tab's folder is not a case, so there is nothing to start beside it.", 'info');
return false;
}
const caseName = worktree ? worktree.caseName : caseEntry.name;
// An ungrouped tab sends no group, so the server's case default group (if the
// case has one) places the new session, as it does for a toolbar Run.
const tabGroupId = this._tabRefLocation({ kind: 'session', id: sessionId })?.groupId || null;
// The phone overview's list, gated by the Run menu's own rule for this case
// (_runModeAvailableForCase: the container's CLIs for a Docker case; shell is
// never gated), in the Run menu's order: agents in catalog order, the shell last.
const isShell = (entry) => registryCliById(entry.mode)?.kind === 'shell';
const available = mobileOverviewRunModes().filter(
(entry) => isShell(entry) || this._runModeAvailableForCase(entry.mode, caseName)
);
const modes = [...available.filter((entry) => !isShell(entry)), ...available.filter(isShell)];
const current = this.runMode;
const actions = modes.map((entry) => ({
label: entry.label,
icon: entry.mode,
className: 'run-mode-option',
selected: entry.mode === current,
run: () =>
this._launchOnce(entry.mode, {
...(worktree ? { worktree } : { caseName }),
...(tabGroupId ? { tabGroupId } : {}),
}),
}));
return this._openTabLayoutMenu(event, `new:${sessionId}`, 'New session here', actions, { anyLayout: true });
}
/**
* The case a session tab belongs to, by what the session runs on. GET /api/cases
* gives a remote or Docker case a DISPLAY path (`user@host:/path`,
* `container:/path`) with the raw values under `remote` / `docker`, while a
* session carries its raw folder, so a path match alone misses those cases or
* lands on a local case at the same folder. A Docker session belongs to its
* container's case on its own engine host, a remote one to the case on its own
* host whose path encloses its own, a local one to the deepest case enclosing
* its folder (a display path never encloses one). Null when none fits.
*/
_tabCaseOf(session) {
if (!session) return null;
const cases = this.cases || [];
const deepestEnclosing = (dir, candidates, rootOf) => {
let best = null;
let bestLength = -1;
for (const c of candidates) {
const root = (rootOf(c) || '').replace(/\/+$/, '');
if (!root || (dir !== root && !dir.startsWith(`${root}/`)) || root.length <= bestLength) continue;
best = c;
bestLength = root.length;
}
return best;
};
if (session.docker) {
const { hostId, containerName } = session.docker;
return (
cases.find((c) => c.location === 'docker' && c.docker?.hostId === hostId && c.docker?.container === containerName) ||
null
);
}
if (session.remote) {
const { hostId, remotePath } = session.remote;
const onHost = cases.filter((c) => c.location === 'remote' && c.remote?.hostId === hostId);
return deepestEnclosing((remotePath || '').replace(/\/+$/, ''), onHost, (c) => c.remote.path);
}
return deepestEnclosing((session.workingDir || '').replace(/\/+$/, ''), cases, (c) => c.path);
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
* Close the open group / web-tab menu. Every dismissal path lands here:
* Escape, a pointer outside it, focus leaving it, Tab, a viewport resize, an
* action, and any full re-render of the rail (which would orphan its trigger).
* Close the open group / web-tab / tab "+" menu. Every dismissal path lands
* here: Escape, a pointer outside it, focus moving to an element outside it,
* Tab, a viewport resize, an action, and any full re-render of the rail (which
* would orphan its trigger).
*/
closeTabGroupMenu({ restoreFocus = false } = {}) {
const menu = this._tabGroupMenu;
@@ -8435,7 +8518,7 @@ class CodemanApp {
]);
}
_openTabLayoutMenu(event, identity, ariaLabel, actions) {
_openTabLayoutMenu(event, identity, ariaLabel, actions, { anyLayout = false } = {}) {
event?.preventDefault?.();
event?.stopPropagation?.();
const trigger = event?.currentTarget || null;
@@ -8446,7 +8529,7 @@ class CodemanApp {
}
this.closeTabGroupMenu();
this.closeTabRailActionMenu?.();
if (!this._tabLayoutEditable()) return false;
if (!anyLayout && !this._tabLayoutEditable()) return false;
const menu = document.createElement('div');
menu.className = 'tab-rail-action-menu tab-layout-group-action-menu';
menu.setAttribute('role', 'menu');
@@ -8455,8 +8538,17 @@ class CodemanApp {
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.icon) {
// A Run menu entry's markup (renderRegistryRunOptions): the CLI's logo, then its name.
const dot = document.createElement('span');
dot.className = `run-mode-dot ${action.icon}`;
dot.setAttribute('aria-hidden', 'true');
button.append(dot, action.label);
} else {
button.textContent = action.label;
}
if (action.className) button.className = action.className;
if (action.selected) button.classList.add('selected');
button.addEventListener('click', () => {
this.closeTabGroupMenu();
action.run();
@@ -8505,13 +8597,18 @@ class CodemanApp {
this.closeTabGroupMenu({ restoreFocus: true });
}
});
// Focus leaving by any other route (a click elsewhere, a programmatic move).
// Hops between the menu's own items are not a departure.
// Focus moving to another element outside the menu (a programmatic move;
// a click elsewhere is the pointerdown listener's). Hops between the
// menu's own items are not a departure, and neither is focus going nowhere:
// Safari never focuses a clicked button, so a press on an item blurs the
// focused one with no relatedTarget, and closing then dropped its click
// (the tile count menu's rule, tile-grid.js).
menu.addEventListener('focusout', (focusEvent) => {
if (focusEvent.relatedTarget && menu.contains(focusEvent.relatedTarget)) return;
const to = focusEvent.relatedTarget;
if (!to || menu.contains(to)) return;
if (this._tabGroupMenu === menu) this.closeTabGroupMenu();
});
menu.querySelector('button')?.focus();
(menu.querySelector('button.selected') || menu.querySelector('button'))?.focus();
return true;
}
+8
View File
@@ -107,6 +107,11 @@
'New session': '新建会话',
// The Cmd+K palette's new-session agent switch (panels-ui.js, its title tooltip).
'Agent for the new session (Left/Right to change)': '新会话使用的智能体(按左/右方向键切换)',
'New session here': '在此新建会话',
'New session here…': '在此新建会话…',
"New session in this tab's case": '在此标签页的案例中新建会话',
"This tab's folder is not a case, so there is nothing to start beside it.":
'此标签页的文件夹不是案例,无法在其旁边新建会话。',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
@@ -483,6 +488,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'New Session Button on Tabs': '标签页新建会话按钮',
'A + on the tab that starts a session in the same case and group, in any installed CLI.':
'标签页上的 + 按钮,可在同一案例和分组中,用任一已安装的 CLI 启动新会话。',
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
+7
View File
@@ -2413,6 +2413,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="new session plus add tab same case group cli">
<div class="set-row-text">
<span class="set-row-label">New Session Button on Tabs</span>
<span class="set-row-desc">A + on the tab that starts a session in the same case and group, in any installed CLI.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabNewButton"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
<div class="set-row-text">
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
+26 -1
View File
@@ -949,6 +949,29 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* The "+" is opt-in (showTabNewButton, per-device; html.tabs-show-new).
Switched on it joins gear + close on the active tab only, and the label
reserve above grows to clear all three (test/mobile-tab-tap-zones). */
.session-tab .tab-new {
display: none;
}
html.tabs-show-new .session-tab.active .tab-new {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
width: 28px;
height: 32px;
padding: 0;
opacity: 0.8;
}
html.tabs-show-new .session-tab.active .tab-name {
min-width: 68px;
}
/* Gear icon on active tab - tiny, subtle */
.session-tab.active .tab-gear {
display: inline-flex;
@@ -3427,7 +3450,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
@media (hover: none) and (pointer: coarse) {
/* Remove hover-dependent UI patterns */
.session-tab .tab-close,
.session-tab .tab-gear {
.session-tab .tab-gear,
.session-tab .tab-new {
opacity: 1;
width: auto;
padding: 0.15rem 0.25rem;
@@ -4127,6 +4151,7 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab {
Matches the (hover: none) block above, but has to be repeated here because
the phone block hides them on non-active tabs with (0,2,0). */
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
display: inline-flex;
align-items: center;
+36 -22
View File
@@ -684,9 +684,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity).
* `options` comes from the tab group menu's "New session": `count` overrides
* the instance steppers for this launch only, `tabGroupId` rides every create
* request so the server places the session in that group.
* `options` comes from the tab group menu's "New session", `_launchOnce` (a
* worktree, a tab's "+"): `count` overrides the instance steppers for this
* launch only, `tabGroupId` rides every create request so the server places the
* session in that group, `caseName` launches in that case instead of the
* toolbar's selected one, and `mode` starts that CLI instead of the Run
* button's mode, which is never touched.
*/
async run(options = {}) {
if (this._runInFlight) return;
@@ -701,7 +704,7 @@ Object.assign(CodemanApp.prototype, {
}
try {
const mode = this._runMode || 'claude';
const mode = options.mode || this._runMode || 'claude';
if (mode === 'shell') {
return await this.runShell(options);
}
@@ -2014,11 +2017,26 @@ Object.assign(CodemanApp.prototype, {
}
},
/** The case a launch runs in: its worktree's, the one it names (a tab's "+"), else the toolbar's. */
_runCaseName(options) {
if (options.worktree) return options.worktree.caseName;
return options.caseName || document.getElementById('quickStartCase').value || 'testcase';
},
/**
* Re-reads the case list after a launch. The no-argument reload re-selects the
* server's `lastUsedCase`, which quick-start has just set to the launched case,
* so a launch in a case it names (a tab's "+") keeps the toolbar on its own.
*/
_reloadCasesAfterRun(options) {
this.loadQuickStartCases(options.caseName ? document.getElementById('quickStartCase').value : null);
},
async runClaude(options = {}) {
// `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in
// that git worktree of the case and its tab is named after the worktree.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
@@ -2123,7 +2141,7 @@ Object.assign(CodemanApp.prototype, {
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
if (remoteIds[0]) {
await this.selectSession(remoteIds[0]);
this.loadQuickStartCases();
this._reloadCasesAfterRun(options);
}
this.terminal.focus();
return;
@@ -2202,7 +2220,7 @@ Object.assign(CodemanApp.prototype, {
// Auto-switch to the new session using selectSession (does proper refresh)
if (firstSessionId) {
await this.selectSession(firstSessionId);
this.loadQuickStartCases();
this._reloadCasesAfterRun(options);
}
this.terminal.focus();
@@ -2249,7 +2267,7 @@ Object.assign(CodemanApp.prototype, {
async runShell(options = {}) {
// `options.worktree`: see runClaude.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
const nameBase = wt ? wt.name : caseName;
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
@@ -2450,7 +2468,7 @@ Object.assign(CodemanApp.prototype, {
// `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the
// session starts through the same path as any other, only in the worktree's folder.
const wt = options.worktree || null;
const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase';
const caseName = this._runCaseName(options);
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
// rejects them for remote cases).
@@ -5754,7 +5772,7 @@ Object.assign(CodemanApp.prototype, {
const created = r.data.worktree;
this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success');
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name: created.name, path: created.path } });
} finally {
if (btn) btn.disabled = false;
}
@@ -5766,26 +5784,22 @@ Object.assign(CodemanApp.prototype, {
if (!caseName || !wt) return;
const mode = this._selectedWorktreeMode();
this.closeWorktreeModal();
await this._launchInWorktree({ caseName, name, path: wt.path }, mode);
await this._launchOnce(mode, { worktree: { caseName, name, path: wt.path } });
},
/**
* Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its
* launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model
* entries use, so the Run button's chosen mode is untouched afterwards.
* Start ONE session of `mode` through run(), the Run button's own entry (its launch lock, its
* per-mode paths), outside the toolbar's choices: `runOptions` names a worktree, or a case and
* tab group (a tab's "+"). The mode rides the call (`run({ mode })`) rather than a swap of the
* Run button's mode, so a mode the user picks while the launch is pending is never overwritten.
*/
async _launchInWorktree(worktree, mode) {
async _launchOnce(mode, runOptions) {
if (this._runInFlight) {
this.showToast?.('A launch is already in progress', 'info');
return;
}
const previous = this._runMode;
this._runMode = mode || previous;
try {
await this.run({ count: 1, worktree });
} finally {
this._runMode = previous;
}
if (runOptions.tabGroupId) this.toggleTabGroupCollapsed?.(runOptions.tabGroupId, false);
await this.run({ count: 1, ...runOptions, ...(mode ? { mode } : {}) });
},
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
+8
View File
@@ -658,6 +658,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowTabDetachButton').checked =
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
// The tab's "+" (applyHeaderVisibilitySettings): per-device, OFF unless switched on.
document.getElementById('appSettingsShowTabNewButton').checked = settings.showTabNewButton === true;
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
@@ -2978,6 +2980,7 @@ Object.assign(CodemanApp.prototype, {
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
showTabNewButton: document.getElementById('appSettingsShowTabNewButton').checked,
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -3249,6 +3252,7 @@ Object.assign(CodemanApp.prototype, {
gitStatusMaxRepos: _gsm,
gitStatusTimeoutSeconds: _gst2,
showTabDetachButton: _tdb,
showTabNewButton: _tnb,
// Detach Tiles: what a tile drag does is a property of this device's
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
tileDetachEnabled: _tde,
@@ -4387,6 +4391,9 @@ Object.assign(CodemanApp.prototype, {
this.tabDetachButtonEnabled?.(settings, defaults)
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
document.documentElement.classList.toggle('tabs-show-detach', showTabDetach);
// The tab's "+" (new session beside it): per-device and opt-in everywhere,
// like the pop-out button. On a phone it also widens the active tab (mobile.css).
document.documentElement.classList.toggle('tabs-show-new', settings.showTabNewButton === true);
const compactHeader = MobileDetection.getDeviceType() !== 'desktop';
const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false);
const showSystemStats = compactHeader ? false : (settings.showSystemStats ?? defaults.showSystemStats ?? true);
@@ -4904,6 +4911,7 @@ Object.assign(CodemanApp.prototype, {
'autoCopySelection', 'copyStripMargin',
'showSessionButton', 'showAwayDigestButton', 'showCronButton', 'showGitStatus', 'gitStatusTree', 'gitStatusMaxRepos', 'gitStatusTimeoutSeconds',
'showTabDetachButton',
'showTabNewButton',
'mobileOverviewEnabled',
'sessionLineageLines',
'showSplitButton',
+30 -1
View File
@@ -2992,6 +2992,33 @@ html[data-header-stats] .header-system-stats .stat-item[hidden] {
transform: rotate(45deg);
}
/* "+" (new session beside this tab, app.openTabNewSessionMenu): same reveal as the gear. */
.session-tab .tab-new {
opacity: 0;
width: 0;
padding: 0;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition: opacity 0.15s, width 0.15s, padding 0.15s;
}
.session-tab.active .tab-new {
opacity: 1;
width: auto;
padding: 0 0.3rem;
}
.session-tab .tab-new:hover {
color: var(--accent-hover);
}
/* Per-device toggle (App Settings → Tab Bar), mirrored as tabs-show-new on <html>
by settings-ui.js. !important so no layout's reveal rule (sidebar, touch) wins. */
html:not(.tabs-show-new) .session-tab .tab-new {
display: none !important;
}
/* ===== Session detach / undock (beta/session-detach) ===================== */
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
@@ -3145,7 +3172,7 @@ html[data-tab-orientation='vertical']:not(.tab-rail-compact) .tab-rail
pointer-events: auto;
}
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-detach, .tab-close) {
html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions > :is(.tab-gear, .tab-new, .tab-detach, .tab-close) {
display: none;
}
@@ -19865,6 +19892,7 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-row > .ta
actions permanently visible on the active session — no layout jitter when
the pointer crosses the list. */
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-gear,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-new,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-detach,
html[data-session-list="sidebar"] .session-sidebar .session-tab.active .tab-close {
opacity: 1;
@@ -20379,6 +20407,7 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .se
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-info,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-number,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-gear,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-new,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-detach,
html[data-session-list="sidebar"][data-sidebar="collapsed"] .session-sidebar .tab-close {
display: none;
+6
View File
@@ -327,6 +327,12 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const actions = [
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
// The row's "+" (showTabNewButton on) as a menu item: its keyboard route, since
// Enter on the "+" itself does nothing, and its only route in the compact rail,
// which hides it. Session rows only: tiles share this menu and carry no "+".
...(document.documentElement.classList.contains('tabs-show-new') && trigger.closest('.session-tab')
? [{ label: 'New session here…', run: () => this.openTabNewSessionMenu({ currentTarget: trigger }, sessionId) }]
: []),
// Focus and group placement (vertical rail with a tab layout only; [] elsewhere).
...(this._tabRefFocusActions?.({ kind: 'session', id: sessionId }) || []),
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),