feat(tabs): place new, child and web tabs into groups

New sessions, spawned child sessions and saved web tabs now land in tab
groups instead of always starting in Ungrouped.

New session in a group:
- The group menu gains "New session" (first item). It expands a collapsed
  group and runs ONE launch with the current case and run mode; a launch
  already in flight is reported, not queued.
- run({ count, tabGroupId }) threads the group through every launch path
  (local POST /api/sessions, remote/docker and external-CLI
  /api/quick-start). The count overrides the steppers for that launch only.
- Server: POST /api/sessions and /api/quick-start accept an optional
  tabGroupId (Zod, 1-100 chars; null/empty is a 400). The routes pass it to
  addSession(), and TabLayoutService.sessionCreated() places the session at
  the end of that group in the SAME versioned write that first records it.
  The session is added to the live map inside the owner lock (with a
  rollback when the write fails), so no queued layout edit can commit it
  first. The group is looked up in the creating owner's own layout, so an
  unknown, deleted or foreign id is ignored instead of failing the spawn. An
  explicit group is a hand placement: a child placed this way is manual.
- The create response carries the committed tabLayout (only when tabGroupId
  was sent), adopted before the new tab is drawn. Responses without the
  field are unchanged.

Child sessions (parentSessionId / X-Codeman-Parent-Session):
- A child nobody placed follows its parent: the server's existing
  normalization already puts it after the parent's rows; the browser now
  draws a not-yet-stored child there too (project({ parents })) and, while
  groups exist, adopts the server's order instead of echoing its own append
  back through PUT /api/session-order (which pinned every child manual).
- The row menu says where a child sits (follows, placed by hand, parent
  closed, parent loop) and offers "Follow parent again" only to a hand-placed
  child. A child whose parent is gone stands on its own and is never
  re-adopted.

Saved web tabs:
- Web tabs are layout refs like sessions; openness stays per device. A
  grouped web tab shows (and takes an Alt+N slot) only while open here and
  reopens where the layout keeps it. Pinned by tests; no behaviour change.

Unchanged: the flat rail and header strip (markup, HTML5 drag, append and
save on create), the tree semantics and single tab stop, sessionOrder and
Alt+N, and every request that does not send tabGroupId.
This commit is contained in:
Aamer Akhter committed 2026-10-09 20:16:45 -04:00
1 parent b59145effd
commit b8ccfc33d1
22 files changed
+1227 -57

No files matched your search

+70 -6
View File
@@ -11,6 +11,7 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
import {
flattenOwnerSessionOrder,
materializeOrphans,
moveRef,
normalizeTabLayout,
TabLayoutValidationError,
validateTabLayout,
@@ -67,6 +68,18 @@ export interface RemovedTabLayoutSession {
owner?: string;
}
/** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */
export interface SessionPlacementHint {
tabGroupId?: string;
}
/**
* Adds the created session to the live map. Runs INSIDE the owner lock, so no
* other layout mutation can observe (and commit) the session first; the returned
* function undoes it when the layout write fails.
*/
export type RegisterCreatedSession = () => void | (() => void);
interface PreparedOwnerLayout {
current: TabLayout | null;
authoritative: TabLayout;
@@ -97,6 +110,25 @@ const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) ==
const sameOrder = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && a.every((id, index) => id === b[index]);
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */
function placeCreatedSession(
layout: TabLayout,
sessionId: string,
groupId: string,
metadata: readonly TabRefMetadata[]
): TabLayout {
const group = layout.groups.find((candidate) => candidate.id === groupId);
if (!group) return layout;
const index = group.refs.filter((ref) => ref.kind !== 'session' || ref.id !== sessionId).length;
try {
return moveRef(layout, { kind: 'session', id: sessionId }, { groupId, index }, metadata);
} catch (error) {
// Not an owner-valid ref of this layout: the hint does not apply.
if (error instanceof TabLayoutValidationError) return layout;
throw error;
}
}
export class TabLayoutService {
private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending';
private readonly ownerQueues = new Map<string, Promise<void>>();
@@ -320,9 +352,13 @@ export class TabLayoutService {
};
}
private async getUnlocked(owner: string): Promise<TabLayout> {
private async getUnlocked(
owner: string,
adjust?: (layout: TabLayout, metadata: readonly TabRefMetadata[]) => TabLayout
): Promise<TabLayout> {
const prepared = await this.prepareUnlocked(owner);
if (!prepared.needsReconciliationCommit) {
const next = adjust ? adjust(prepared.authoritative, prepared.metadata) : prepared.authoritative;
if (!prepared.needsReconciliationCommit && sameLayout(next, prepared.authoritative)) {
const publication = {
owner,
previous: prepared.current,
@@ -335,7 +371,7 @@ export class TabLayoutService {
return prepared.authoritative;
}
const base = prepared.current ?? { ...prepared.authoritative, version: -1 };
return this.commit(owner, base, prepared.authoritative, prepared.metadata);
return this.commit(owner, base, next, prepared.metadata, prepared.current);
}
async get(owner: string): Promise<TabLayout> {
@@ -442,9 +478,37 @@ export class TabLayoutService {
}
}
/** Reconcile one completed session creation into one versioned mutation. */
async sessionCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
/**
* Reconcile one completed session creation into one versioned mutation.
*
* `placement.tabGroupId` puts the new session at the end of that group in the
* same write. The group is looked up in THIS owner's layout only, so a group id
* from another owner (or one deleted meanwhile) is simply not found and the
* session keeps its normal placement: after its parent when it has one,
* otherwise at the end of Ungrouped. An explicit group is a hand placement, so
* a child session placed this way is marked `manual` and stops following.
*/
async sessionCreated(
owner: string,
sessionId?: string,
placement: SessionPlacementHint = {},
register?: RegisterCreatedSession
): Promise<TabLayout> {
return this.withOwner(owner, async () => {
const rollback = register?.();
try {
const groupId = placement.tabGroupId;
return await this.getUnlocked(
owner,
sessionId && groupId
? (layout, metadata) => placeCreatedSession(layout, sessionId, groupId, metadata)
: undefined
);
} catch (error) {
if (typeof rollback === 'function') rollback();
throw error;
}
});
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
+8 -1
View File
@@ -5,10 +5,17 @@
import type { Session } from '../../session.js';
import type { SessionState } from '../../types.js';
import type { TabLayout } from '../../tab-layout.js';
import type { SessionPlacementHint } from '../../tab-layout-service.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): Promise<void>;
/**
* Register a created session with the owner's tab layout. `placement.tabGroupId`
* puts it at the end of that group in the same layout write (ignored when the
* group is not in the owner's layout). Resolves to the committed layout.
*/
addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+74 -5
View File
@@ -2287,10 +2287,17 @@ class CodemanApp {
_onSessionCreated(data) {
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
if (this._serverPlacesSessions()) {
// The server already placed it (a child after its parent, a grouped
// creation in its group) and broadcast that order; adopt it. Echoing a
// local append back as a hand order would pin every child in place.
this.syncSessionOrder();
} else {
// Add new session to end of tab order
this.sessionOrder.push(data.id);
this.saveSessionOrder();
}
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
@@ -7371,6 +7378,8 @@ class CodemanApp {
collapsedGroupIds: [...this.collapsedTabGroupIds],
activeSessionId: this.activeSessionId,
activeWebviewId: this.activeWebviewId,
// A session spawned by another one shows up in its parent's group at once.
parents: this._tabLayoutParents(),
});
}
@@ -7419,6 +7428,18 @@ class CodemanApp {
return !!(this.tabLayout && window.CodemanTabLayout && this._tabOrientation() === 'vertical');
}
/**
* True while the owner has tab groups: session creation and deletion are then
* placed by the server (TabLayoutService), which broadcasts the resulting
* order. The browser must not echo its own guess back through
* PUT /api/session-order, which the server reads as a hand arrangement and
* answers by pinning every child session where it stands. Without groups the
* strip keeps its long-standing append-and-save behaviour.
*/
_serverPlacesSessions() {
return !!(this.tabLayout && window.CodemanTabLayout?.hasGroups(this.tabLayout));
}
/** child session id -> parent session id, so a moved session takes the sessions that follow it. */
_tabLayoutParents() {
const parents = {};
@@ -7568,8 +7589,8 @@ class CodemanApp {
if (!this._tabLayoutEditable()) return [];
const location = this._tabRefLocation(ref);
if (!location) return [];
const actions = [];
const grouped = this.tabLayout.groups.length > 0;
const actions = grouped ? this._tabRefPlacementActions(ref) : [];
// Up/down follow the STORED order, which is what the rail paints unless a
// sort is on (then the sort decides and there is nothing to reorder).
if (grouped && !this.isTabRailSorted()) {
@@ -7594,6 +7615,52 @@ class CodemanApp {
return actions;
}
/**
* A child session's placement, for its row menu: an informational line (where
* it sits and why, never actionable) and, for a child placed by hand, "Follow
* parent again". A child follows its parent into the parent's group until it
* is moved by hand; a child whose parent is gone stands on its own and is
* never re-adopted automatically.
*/
_tabRefPlacementActions(ref) {
const parents = this._tabLayoutParents();
const placement = window.CodemanTabLayout.placementState(this.tabLayout, ref, parents);
if (!placement) return [];
const parent = this.sessions.get(placement.parentId);
const parentName = parent ? this.getSessionName(parent) : placement.parentId;
const summary = {
inherited: `Follows ${parentName}`,
manual: `Placed by hand (parent: ${parentName})`,
dangling: 'Parent closed; placed on its own',
cycle: 'Parent loop; placed on its own',
}[placement.state];
const actions = [{ label: summary, disabled: true }];
if (placement.canFollow) {
actions.push({
label: 'Follow parent again',
run: () => this.editTabLayout({ type: 'followParent', ref, parents: this._tabLayoutParents() }, `${ref.kind}:${ref.id}`),
});
}
return actions;
}
/**
* The group menu's "New session": launch ONE session with the current case and
* run mode, placed at the end of this group by the server in the same write
* that records it (the group id rides the create request). A collapsed group
* expands first so the new tab is visible when it arrives.
*/
async createSessionInTabGroup(groupId) {
if (!this._tabLayoutEditable() || !this.tabLayout.groups.some((group) => group.id === groupId)) return false;
if (this._runInFlight) {
this.showToast?.('A session is already starting.', 'info');
return false;
}
this.toggleTabGroupCollapsed(groupId, false);
await this.run({ count: 1, tabGroupId: groupId });
return true;
}
// ─── Group and web-tab menus (right-click, the header's ⋯, Shift+F10) ──
/**
@@ -7634,6 +7701,7 @@ class CodemanApp {
const index = groups.findIndex((group) => group.id === groupId);
if (index < 0) return false;
return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [
{ label: 'New session', run: () => this.createSessionInTabGroup(groupId) },
...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []),
{ label: 'Rename group', run: () => this.startTabGroupRename(groupId) },
...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []),
@@ -9591,7 +9659,8 @@ class CodemanApp {
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
// With groups, the server's deletion already re-projected the order.
if (!this._serverPlacesSessions()) this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
+8
View File
@@ -90,6 +90,11 @@
'Move down': '下移',
'Move to Ungrouped': '移到未分组',
'Move to new group': '移到新分组',
'New session': '新建会话',
'A session is already starting.': '已有会话正在启动。',
'Follow parent again': '重新跟随父会话',
'Parent closed; placed on its own': '父会话已关闭;单独放置',
'Parent loop; placed on its own': '父会话形成循环;单独放置',
'Could not save tab groups.': '无法保存标签分组。',
'Tab groups changed elsewhere; part of your edit no longer applies.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
@@ -1172,6 +1177,9 @@
[/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
[/^Follows (.+)$/, (_m, parent) => `跟随 ${parent}`],
[/^Placed by hand \(parent: (.+)\)$/, (_m, parent) => `已手动放置(父会话:${parent})`],
[
/^Delete group "(.+)"\? Its tabs move to Ungrouped\.$/,
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
+35 -22
View File
@@ -621,9 +621,12 @@ Object.assign(CodemanApp.prototype, {
/** Ensure a newly-created session is visible without waiting for the SSE event.
* The POST response and session:created can arrive in either order, so the
* normal idempotent SSE handler remains the single state-upsert path. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot) {
* normal idempotent SSE handler remains the single state-upsert path.
* `tabLayout` is the layout a grouped creation returns (see run()'s
* `tabGroupId`): adopting it first draws the new tab inside its group. */
async _ensureCreatedSessionVisible(sessionId, sessionSnapshot, tabLayout) {
if (!sessionId) return;
if (tabLayout) this._applyTabLayout?.(tabLayout);
let session = sessionSnapshot;
if (!session && !this.sessions?.has(sessionId)) {
@@ -643,8 +646,13 @@ Object.assign(CodemanApp.prototype, {
this._renderSessionTabsImmediate?.();
},
/** Run using the selected mode (Claude Code, OpenCode, Codex, Gemini, or Antigravity) */
async run() {
/**
* 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.
*/
async run(options = {}) {
if (this._runInFlight) return;
const startedAt = Date.now();
@@ -659,12 +667,12 @@ Object.assign(CodemanApp.prototype, {
try {
const mode = this._runMode || 'claude';
if (mode === 'shell') {
return await this.runShell();
return await this.runShell(options);
}
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
return await this.runClaude();
return await this.runClaude(options);
}
return await this._runCliMode(mode);
return await this._runCliMode(mode, options);
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
@@ -1910,9 +1918,10 @@ Object.assign(CodemanApp.prototype, {
}
},
async runClaude() {
async runClaude(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = this._readTabCount();
const tabCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} Claude session(s) in ${caseName}...`
@@ -1972,6 +1981,7 @@ Object.assign(CodemanApp.prototype, {
for (let i = 0; i < tabCount; i++) {
const quickStartBody = JSON.stringify({
caseName, mode: 'claude', sessionName: `w${startNumber + i}-${caseName}`,
...group,
...(dockerModelOverride !== undefined ? { modelOverride: dockerModelOverride } : {})
});
const doQuickStart = async () => {
@@ -2000,7 +2010,7 @@ Object.assign(CodemanApp.prototype, {
}
}
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
remoteIds.push(data.data.sessionId);
}
this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`);
@@ -2046,6 +2056,7 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir, name,
...group,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(advisorModel ? { advisorModel } : {}),
@@ -2059,7 +2070,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
sessionIds.push(result.data.session.id);
}
firstSessionId = sessionIds[0];
@@ -2128,11 +2139,12 @@ Object.assign(CodemanApp.prototype, {
}
},
async runShell() {
async runShell(options = {}) {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Run Shell reads the toolbar's one instance stepper, like every other run*();
// its own second `− 1 +` group (#shellCount) was removed (#428).
const shellCount = this._readTabCount();
const shellCount = this._readTabCount(options.count);
const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {};
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${shellCount} Shell session(s) in ${caseName}...`,
@@ -2178,11 +2190,11 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}` })
body: JSON.stringify({ caseName, mode: 'shell', sessionName: `s${startNumber + i}-${caseName}`, ...group })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
@@ -2208,7 +2220,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name, ...group })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -2216,7 +2228,7 @@ Object.assign(CodemanApp.prototype, {
const sessionIds = [];
for (const result of createResults) {
if (!result.success) throw new Error(result.error);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session);
await this._ensureCreatedSessionVisible(result.data.session.id, result.data.session, result.data.tabLayout);
sessionIds.push(result.data.session.id);
}
@@ -2257,8 +2269,8 @@ Object.assign(CodemanApp.prototype, {
* their try block, to put the count in the opening banner: `#tabCount` ships
* unconditionally today, but a throw here would escape the launch-error path.
*/
_readTabCount() {
return Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount')?.value) || 1));
_readTabCount(override) {
return Math.min(20, Math.max(1, override ?? (parseInt(document.getElementById('tabCount')?.value) || 1)));
},
/**
@@ -2291,7 +2303,7 @@ Object.assign(CodemanApp.prototype, {
);
if (!data.success) throw new Error(data.error || `Failed to start ${label}`);
customModelAnswered = true;
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session, data.data.tabLayout);
if (!firstSessionId) firstSessionId = data.data.sessionId;
}
if (tabCount > 1) {
@@ -2309,7 +2321,7 @@ Object.assign(CodemanApp.prototype, {
* menu call them directly by name (`app.runOpenCode()` etc.), and several
* tests assert on that name directly too.
*/
async _runCliMode(mode) {
async _runCliMode(mode, options = {}) {
const catalogEntry = registryCliById(mode);
const entry = RUN_MODE_LAUNCH[mode] ||
(catalogEntry && {
@@ -2326,7 +2338,7 @@ Object.assign(CodemanApp.prototype, {
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const tabCount = this._readTabCount();
const tabCount = this._readTabCount(options.count);
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
);
@@ -2364,6 +2376,7 @@ Object.assign(CodemanApp.prototype, {
caseName,
mode,
sessionName,
...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}),
...(isRemote ? {} : {
...(entry.buildConfig(globalSettings) || {}),
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
+11
View File
@@ -2909,6 +2909,17 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
color: var(--red);
}
/* A child session's placement line: information, not an action. */
.tab-rail-action-menu button.tab-rail-action-menu-note {
color: var(--text-muted);
font-size: 0.85em;
cursor: default;
}
.tab-rail-action-menu button.tab-rail-action-menu-note:hover {
background: transparent;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-range-field {
display: grid;
grid-template-columns: minmax(112px, 1fr) 44px;
+110 -2
View File
@@ -131,6 +131,12 @@
* tab, else the active session), so selecting a hidden session by keyboard,
* palette or Alt+N never leaves the user with no visible selection.
*
* `options.parents` (child session id -> parent session id) places a live
* session the layout has not stored yet right after its parent's rows when
* the parent IS stored: that is where the server puts a child that follows its
* parent, so a spawned session appears in its parent's group at once instead
* of jumping there from Ungrouped once the layout re-read lands.
*
* @returns {null | { sections, visibleRefs, hiddenTabGroupByRef, sectionByRef }}
* null when the layout has no groups: the caller renders the flat rail
* unchanged. Each section lists the rows it shows (`refs`) and the rows its
@@ -152,6 +158,7 @@
? `session:${options.activeSessionId}`
: '';
const renderable = (ref) => (ref.kind === 'session' ? live.has(ref.id) : open.has(ref.id));
const withFollowers = followingChildren(layout, liveSessionIds, options.parents);
const placed = new Set();
const visibleRefs = [];
const hiddenTabGroupByRef = {};
@@ -182,14 +189,14 @@
for (const group of layout.groups) {
const isCollapsed = collapsed.has(group.id);
const { shown, hidden, count } = place(group.refs, group.id, isCollapsed);
const { shown, hidden, count } = place(withFollowers(group.refs), group.id, isCollapsed);
sections.push({ id: group.id, name: group.name, refs: shown, hidden, count, collapsed: isCollapsed });
}
const omissions = [
...liveSessionIds.map((id) => ({ kind: 'session', id })),
...openWebviewIds.map((id) => ({ kind: 'webview', id })),
];
const ungrouped = place([...layout.ungrouped, ...omissions], null, false);
const ungrouped = place([...withFollowers(layout.ungrouped), ...omissions], null, false);
if (ungrouped.count > 0) {
sections.push({
id: null,
@@ -227,6 +234,47 @@
return result;
}
/**
* For project(): returns `expand(refs)`, which inserts each live, not-yet-stored
* child session right after its parent's rows in the parent's container. A
* child of such a child follows it the same way. Without `parents` it is the
* identity, so callers that pass no lineage see no change.
*/
function followingChildren(layout, liveSessionIds, parents) {
if (!parents || typeof parents !== 'object') return (refs) => refs;
const stored = new Set(refLocations(layout).map((item) => refKey(item.ref)));
const accepted = new Set();
const pending = new Map();
// Accept parents before their children (bounded: one pass per accepted child).
for (let changed = true; changed; ) {
changed = false;
for (const id of liveSessionIds) {
const key = `session:${id}`;
const parentId = parents[id];
if (stored.has(key) || accepted.has(key) || typeof parentId !== 'string' || parentId === id) continue;
const parentKey = `session:${parentId}`;
if (!stored.has(parentKey) && !accepted.has(parentKey)) continue;
accepted.add(key);
if (!pending.has(parentKey)) pending.set(parentKey, []);
pending.get(parentKey).push({ kind: 'session', id });
changed = true;
}
}
if (!pending.size) return (refs) => refs;
return (refs) => {
const out = [...refs];
for (const [parentKey, children] of pending) {
const block = lineageBlock(layout, { kind: 'session', id: parentKey.slice(8) }, parents);
let at = -1;
out.forEach((ref, index) => {
if (block.has(refKey(ref))) at = index;
});
if (at >= 0) out.splice(at + 1, 0, ...children);
}
return out;
};
}
/**
* Everything that changes the grouped rail's STRUCTURE (which rows exist and
* where, the headers' names, what a collapse hides), as opposed to a row's own
@@ -418,6 +466,35 @@
return keys;
}
/**
* How a child session is placed, for the row menu. null for a session with no
* parent (or not stored). Otherwise `{ state, parentId, canFollow }`:
* - inherited: it follows its parent (moves with it).
* - manual: placed by hand; "Follow parent again" puts it back.
* - dangling: its parent is not in this layout (closed, or another owner's),
* so it stands on its own; nothing re-adopts it automatically.
* - cycle: the parent chain loops back to it; it stands on its own.
*/
function placementState(layoutInput, ref, parents) {
const layout = normalizeLayout(layoutInput);
if (!validRef(ref) || ref.kind !== 'session') return null;
const parentId = parents?.[ref.id];
if (typeof parentId !== 'string' || !parentId || parentId === ref.id) return null;
const stored = new Map(refLocations(layout).map((item) => [refKey(item.ref), item.ref]));
const own = stored.get(refKey(ref));
if (!own) return null;
if (!stored.has(`session:${parentId}`)) return { state: 'dangling', parentId, canFollow: false };
// Walk up the stored ancestors; meeting this session again is a loop.
const seen = new Set([ref.id]);
for (let id = parentId; id && stored.has(`session:${id}`); id = parents[id]) {
if (seen.has(id)) return { state: 'cycle', parentId, canFollow: false };
seen.add(id);
}
return own.placement === 'manual'
? { state: 'manual', parentId, canFollow: true }
: { state: 'inherited', parentId, canFollow: false };
}
/**
* Where a moved row lands, as the server's `index` (counted AFTER the moved
* block is taken out): before or after `anchor` in that container, or at its
@@ -546,6 +623,36 @@
destination.splice(clampIndex(op.index, destination.length), 0, ...block);
return layout;
}
case 'followParent': {
// Clear a hand placement: the child (with the sessions that follow it)
// goes back after its parent's rows. The server re-derives the exact
// order on save; this only has to put the row in the right group now.
const state = placementState(layout, op.ref, op.parents);
if (!state || state.state === 'dangling' || state.state === 'cycle') editError('no parent to follow');
if (state.state !== 'manual') return layout;
const targetKey = refKey(op.ref);
const clear = (ref) => (refKey(ref) === targetKey ? { kind: ref.kind, id: ref.id } : ref);
for (const group of layout.groups) group.refs = group.refs.map(clear);
layout.ungrouped = layout.ungrouped.map(clear);
const childKeys = new Set();
for (const key of lineageBlock(layout, op.ref, op.parents)) childKeys.add(key);
const block = refLocations(layout)
.filter((item) => childKeys.has(refKey(item.ref)))
.map((item) => copyRef(item.ref));
block.sort((a, b) => (refKey(a) === targetKey ? -1 : refKey(b) === targetKey ? 1 : 0));
for (const group of layout.groups) group.refs = group.refs.filter((ref) => !childKeys.has(refKey(ref)));
layout.ungrouped = layout.ungrouped.filter((ref) => !childKeys.has(refKey(ref)));
const parentRef = { kind: 'session', id: state.parentId };
const home = refLocations(layout).find((item) => refKey(item.ref) === refKey(parentRef));
const container = containerRefs(layout, home.groupId);
const parentBlock = lineageBlock(layout, parentRef, op.parents);
let at = -1;
container.forEach((ref, index) => {
if (parentBlock.has(refKey(ref))) at = index;
});
container.splice(at + 1, 0, ...block);
return layout;
}
default:
return editError(`unknown operation ${op.type}`);
}
@@ -739,6 +846,7 @@
applyOperation,
moveDestination,
movingRefKeys: (layout, ref, parents) => [...lineageBlock(normalizeLayout(layout), ref, parents)],
placementState,
dropOperation,
contentKey,
createEditCoordinator,
+6
View File
@@ -341,7 +341,13 @@ Object.assign(CodemanApp.prototype, {
button.setAttribute('role', 'menuitem');
button.textContent = action.label;
if (action.className) button.className = action.className;
// An informational line (a child session's placement): readable, never acts.
if (action.disabled) {
button.setAttribute('aria-disabled', 'true');
button.classList.add('tab-rail-action-menu-note');
}
button.addEventListener('click', () => {
if (action.disabled) return;
this.closeTabRailActionMenu();
action.run();
});
+9 -3
View File
@@ -1223,7 +1223,9 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
await ctx.addSession(session);
const tabLayout = body.tabGroupId
? await ctx.addSession(session, { tabGroupId: body.tabGroupId })
: await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -1240,7 +1242,9 @@ export function registerSessionRoutes(
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { session: lightState };
// A grouped creation also returns the layout that placed it, so the browser
// draws the tab inside its group at once instead of after a re-read.
return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) };
});
// ========== Rename Session ==========
@@ -3485,6 +3489,7 @@ export function registerSessionRoutes(
parentSessionId,
agentOrigin,
customModel,
tabGroupId,
} = parseBody(QuickStartSchema, req.body);
// Resolved ONCE here: the same value labels a case directory this request creates
@@ -4137,7 +4142,7 @@ export function registerSessionRoutes(
}
}
await ctx.addSession(session);
const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -4222,6 +4227,7 @@ export function registerSessionRoutes(
casePath: resolvedCasePath,
caseName,
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
+13
View File
@@ -533,12 +533,23 @@ export const DeepSeekWebStartSchema = z
*/
const parentSessionIdSchema = z.string().max(100).optional();
/**
* Tab group the new session should be placed in (the group's "New session"
* action). Advisory like parentSessionId: the layout service looks the id up in
* the creating owner's own layout and ignores one it cannot find, so a stale or
* foreign id never fails a spawn. A malformed value (null, empty, over-long) is
* still a 400, because only the browser sends it.
*/
const tabGroupIdSchema = z.string().min(1).max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: sessionModeSchema().optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -1080,6 +1091,8 @@ export const QuickStartSchema = z.object({
sessionName: z.string().max(128).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
/** Tab group to place the new session in: see tabGroupIdSchema. */
tabGroupId: tabGroupIdSchema,
/** Model override written to <case>/.claude/settings.local.json (e.g. "opus[1m]").
* Empty string clears. Applied for local AND docker cases (the docker workspace is
* a real host dir, so the settings file crosses the bind mount); rejected for
+15 -10
View File
@@ -59,7 +59,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js';
import { TabLayoutService } from '../tab-layout-service.js';
import { TabLayoutService, type SessionPlacementHint } from '../tab-layout-service.js';
import type { TabLayout } from '../tab-layout.js';
import { ownerLayoutKey } from '../tab-layout-persistence.js';
import { readWebviews } from '../webview-store.js';
import { extractCompletionPhrase } from '../ralph-config.js';
@@ -708,15 +709,19 @@ export class WebServer extends EventEmitter {
}
}
/** Add a tentative session only after its owner layout accepts the creation. */
private async registerSessionWithLayout(session: Session): Promise<void> {
this.sessions.set(session.id, session);
try {
await this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner));
} catch (error) {
this.sessions.delete(session.id);
throw error;
}
/**
* Add a tentative session only after its owner layout accepts the creation.
* The session enters the live map inside the layout service's owner lock, so
* the placement it asked for (`tabGroupId`) lands in the same versioned write
* that first records it.
*/
private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> {
return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => {
this.sessions.set(session.id, session);
return () => {
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
};
});
}
/**