Merge pull request #528: feat(tabs): place new, child and web tabs into groups

Conflict resolved in docs/api-reference.md: #607's prompt cache section and
#528's tabGroupId section are both kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 18:10:37 +02:00
commit a38eab49cd
30 files changed
+1932 -79

No files matched your search

+68 -12
View File
@@ -32,29 +32,85 @@ export function applyLegacySessionRank(
const isRanked = (ref: TabRef): boolean =>
ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id);
const prepare = (ref: TabRef): TabRef => {
if (ref.kind !== 'session') return { ...ref };
const item = sessionMetadata.get(ref.id);
const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId);
return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref };
};
const rankContainer = (refs: readonly TabRef[]): TabRef[] => {
const ranked = refs
.filter(isRanked)
.map(prepare)
.sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
const ranked = refs.filter(isRanked).sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!);
let rankedIndex = 0;
return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref }));
return refs.map((ref) => ({ ...(isRanked(ref) ? ranked[rankedIndex++] : ref) }));
};
const transformed: TabLayout = {
const ranked: TabLayout = {
...layout,
groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })),
ungrouped: rankContainer(layout.ungrouped),
};
const manual = childrenSplitFromParent(ranked, sessionMetadata);
const pin = (ref: TabRef): TabRef =>
ref.kind === 'session' && manual.has(ref.id) ? { ...ref, placement: 'manual' } : ref;
const transformed: TabLayout = {
...ranked,
groups: ranked.groups.map((group) => ({ ...group, refs: group.refs.map(pin) })),
ungrouped: ranked.ungrouped.map(pin),
};
return normalizeTabLayout(transformed, metadata);
}
/**
* The following children (no `manual` placement, an owner-valid parent) that a
* ranked layout separates from their parent's block. A parent's block is the
* parent and, right after it in the same container, its following descendants;
* sibling order inside the block is free, and a web tab the ranking left in a
* slot between them does not split it (normalization closes the block again). Only these children become `manual`:
* a PUT of the current order, or one that moves a family together, leaves every
* stored placement alone. Pinning one child shrinks its parent's block, so the
* check repeats until nothing more is pinned.
*/
function childrenSplitFromParent(ranked: TabLayout, sessionMetadata: ReadonlyMap<string, TabRefMetadata>): Set<string> {
const containers = [...ranked.groups.map((group) => group.refs), ranked.ungrouped];
const following = new Map<string, string>();
for (const refs of containers) {
for (const ref of refs) {
if (ref.kind !== 'session' || ref.placement === 'manual') continue;
const parentId = sessionMetadata.get(ref.id)?.parentSessionId;
if (parentId && sessionMetadata.has(parentId)) following.set(ref.id, parentId);
}
}
const descendsFrom = (id: string, ancestorId: string): boolean => {
let current = following.get(id);
for (let hops = 0; current !== undefined && hops <= following.size; hops++) {
if (current === ancestorId) return true;
current = following.get(current);
}
return false;
};
const manual = new Set<string>();
for (let changed = true; changed; ) {
changed = false;
for (const refs of containers) {
const index = new Map<string, number>();
refs.forEach((ref, at) => {
if (ref.kind === 'session') index.set(ref.id, at);
});
refs.forEach((ref, at) => {
if (ref.kind !== 'session') return;
const parentId = following.get(ref.id);
if (parentId === undefined) return;
const parentAt = index.get(parentId);
const inBlock =
parentAt !== undefined &&
parentAt < at &&
refs
.slice(parentAt + 1, at)
.every((between) => between.kind !== 'session' || descendsFrom(between.id, parentId));
if (inBlock) return;
following.delete(ref.id);
manual.add(ref.id);
changed = true;
});
}
}
return manual;
}
export function recomposeGlobalSessionOrder(
current: readonly string[],
projections: readonly OwnerOrderProjection[],
+103 -6
View File
@@ -11,7 +11,9 @@ import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layou
import {
flattenOwnerSessionOrder,
materializeOrphans,
moveRef,
normalizeTabLayout,
setManualPlacement,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
@@ -42,6 +44,12 @@ interface TabLayoutServiceDeps {
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
broadcast(event: string, data: unknown): void;
broadcastSessionOrder(change: SessionOrderProjectionChange): void;
/**
* The synced `spawnedTabsFollowParent` setting, read at every creation. Off (or
* absent), a spawned session lands after its parent once and is hand-placed
* from then on; on, it keeps following its parent until moved by hand.
*/
childrenFollowParent?(): boolean | Promise<boolean>;
now?: () => string;
}
@@ -67,6 +75,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 +117,42 @@ 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]);
/**
* Hand-place a just-created child where normalization put it (right after its
* parent), so it stops following. A session that does not follow anything is
* left alone.
*/
function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): TabLayout {
const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId);
const parentId = item?.parentSessionId;
if (!item?.ownerValid || !parentId) return layout;
const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped];
const ref = refs.find((candidate) => candidate.kind === 'session' && candidate.id === sessionId);
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
return setManualPlacement(layout, ref, true);
}
/** 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 +376,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 +395,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 +502,46 @@ 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.
*
* Whether a child placed by its lineage KEEPS following is the synced
* `spawnedTabsFollowParent` setting, read here at creation: off (the default),
* the child lands after its parent once and is marked `manual` in the same
* write; on, it follows its parent until moved by hand. Flipping the setting
* later leaves existing tabs as they are.
*/
async sessionCreated(
owner: string,
sessionId?: string,
placement: SessionPlacementHint = {},
register?: RegisterCreatedSession
): Promise<TabLayout> {
return this.withOwner(owner, async () => {
const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true;
const rollback = register?.();
try {
const groupId = placement.tabGroupId;
const adjust =
sessionId && (groupId || !follow)
? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => {
const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout;
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
}
: undefined;
return await this.getUnlocked(owner, adjust);
} 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;
+96 -5
View File
@@ -704,6 +704,9 @@ const URL_SESSION_WAIT_MS = 30000;
*/
const TAB_LAYOUT_PENDING_MAX_AGE_MS = 60000;
/** How long a collapsed group's header stays marked after a tab arrives in it. */
const TAB_GROUP_ARRIVAL_MS = 2000;
class CodemanApp {
constructor() {
this.sessions = new Map();
@@ -2307,10 +2310,13 @@ class CodemanApp {
// A session this tab is closing stays closed until the server answers.
if (this._closingSessions?.has(data.id)) return;
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();
// The server placed it when it recorded the creation (a child after its
// parent, a grouped creation in its group) and broadcast that order
// (session:orderChanged); adopt it, appending only while that broadcast is
// still on its way. Never echo it back through PUT /api/session-order:
// the server reads that as a hand arrangement of every tab.
this.syncSessionOrder();
}
// Idempotent per id: the POST response and the session:created event both
// land here, and a batch launched together cascades in creation order.
@@ -6994,6 +7000,7 @@ class CodemanApp {
this._applyTabTreeSemantics(container, { identity: focusIdentity, refocus: focusWasInside });
this._syncTabGroupHeaderAlerts(container, groupProjection);
}
this._noteTabGroupArrivals(container, groupProjection);
this._syncTabTriageChrome(container, triage);
this._syncTabArrangementClasses(container, { clusters: !!clusterLayout, groupProjection });
const activeBandMoved = this._noteActiveTabBand(container) && this._isScrollingTabRow(container);
@@ -7268,6 +7275,38 @@ class CodemanApp {
}
}
/**
* A tab that first appears inside a COLLAPSED group (a spawned child, a session
* another device placed there) would otherwise arrive silently, the header
* count being its only trace. Its group header is marked for a moment instead
* (`.tab-layout-group-arrived`). Only a ref absent from the previous grouped
* render counts, so collapsing a group, or the first paint, marks nothing.
*/
_noteTabGroupArrivals(container, projection) {
const previous = this._renderedTabGroupRefs;
this._renderedTabGroupRefs = projection ? new Set(Object.keys(projection.sectionByRef || {})) : null;
if (!projection) return;
const now = Date.now();
const arrivals = (this._tabGroupArrivals ||= new Map());
if (previous) {
for (const [key, groupId] of Object.entries(projection.hiddenTabGroupByRef || {})) {
if (previous.has(key)) continue;
arrivals.set(groupId, now + TAB_GROUP_ARRIVAL_MS);
setTimeout(() => {
if ((arrivals.get(groupId) ?? 0) > Date.now()) return;
arrivals.delete(groupId);
for (const header of this.$('sessionTabs')?.querySelectorAll('[data-tab-group-header]') || []) {
if (header.dataset.tabGroupHeader === groupId) header.classList.remove('tab-layout-group-arrived');
}
}, TAB_GROUP_ARRIVAL_MS);
}
}
for (const header of container.querySelectorAll('[data-tab-group-header]')) {
const until = arrivals.get(header.dataset.tabGroupHeader) ?? 0;
header.classList.toggle('tab-layout-group-arrived', until > now);
}
}
/** Keep aria-selected on the grouped tree in step with the .active class. */
_syncTabTreeSelection(container) {
if (container?.getAttribute('role') !== 'tree') return;
@@ -7590,6 +7629,8 @@ class CodemanApp {
collapsedGroupIds: this._tabRailSearchActive() ? [] : [...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(),
});
}
@@ -7790,8 +7831,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()) {
@@ -7816,6 +7857,55 @@ class CodemanApp {
return actions;
}
/**
* A child session's placement, for its row menu: an informational note (where
* it sits and why: plain text, never focusable) 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) {
// Following is the synced "Spawned tabs follow their parent" setting (off by
// default): off, every spawned tab is hand-placed, so there is nothing to say.
if (this.loadAppSettingsFromStorage?.()?.spawnedTabsFollowParent !== true) return [];
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, note: 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) ──
/**
@@ -7856,6 +7946,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 }) }] : []),
@@ -9895,8 +9986,8 @@ class CodemanApp {
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
// Not saved: the server's deletion already re-projected and broadcast the order.
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
+11
View File
@@ -93,6 +93,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.':
'标签分组已在别处更改;你的部分编辑已不再适用。',
@@ -461,6 +466,9 @@
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
'Auto-name Sessions': '自动命名会话',
'Spawned Tabs Follow Their Parent': '派生的标签跟随父会话',
"A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.":
'由另一个会话启动的会话会跟随父会话的标签进入其分组并随之移动,直到你手动移动它。关闭时:它出现在父会话旁边并停留在原处。',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
@@ -1215,6 +1223,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}”?其中的标签将移到未分组。`,
+7
View File
@@ -2282,6 +2282,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoNameSessions"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsSpawnedTabsFollowParentItem" data-search="spawned child session follow parent tab group lineage move">
<div class="set-row-text">
<span class="set-row-label">Spawned Tabs Follow Their Parent <span class="set-tag">synced</span></span>
<span class="set-row-desc">A session started by another one keeps following its parent's tab into its group and moves with it, until you move it by hand. Off: it starts next to its parent and stays where it is.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsSpawnedTabsFollowParent"><span class="slider"></span></label>
</div>
<div class="set-row" id="appSettingsMobileOverviewItem" data-search="overview home screen phone logo">
<div class="set-row-text">
<span class="set-row-label">Overview Home Screen <span class="set-tag">phone</span></span>
+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 } : {}),
+3
View File
@@ -442,6 +442,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true;
// Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true;
// Spawned tabs follow their parent: synced (the server places the tab), default OFF.
document.getElementById('appSettingsSpawnedTabsFollowParent').checked = settings.spawnedTabsFollowParent === true;
const lineageItem = document.getElementById('appSettingsLineageLinesItem');
if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false;
@@ -2582,6 +2584,7 @@ Object.assign(CodemanApp.prototype, {
mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked,
sessionLineageLines: document.getElementById('appSettingsLineageLines').checked,
autoNameSessions: document.getElementById('appSettingsAutoNameSessions').checked,
spawnedTabsFollowParent: document.getElementById('appSettingsSpawnedTabsFollowParent').checked,
showSessionButton: document.getElementById('appSettingsShowSessionButton').checked,
showAwayDigestButton: document.getElementById('appSettingsShowAwayDigestButton').checked,
showCronButton: document.getElementById('appSettingsShowCronButton').checked,
+21
View File
@@ -716,6 +716,19 @@ html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header {
text-align: left;
}
/* A tab just arrived inside this collapsed group: a short highlight on its header. */
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
color: var(--text);
background: color-mix(in srgb, var(--accent) 22%, transparent);
transition: background-color 0.6s ease-out, color 0.6s ease-out;
}
@media (prefers-reduced-motion: reduce) {
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-header.tab-layout-group-arrived {
transition: none;
}
}
html[data-tab-orientation='vertical'] .tab-rail .tab-layout-group-toggle {
cursor: pointer;
user-select: none;
@@ -2927,6 +2940,14 @@ html[data-tab-orientation='vertical'].tab-rail-compact .session-tab .tab-actions
color: var(--red);
}
/* A child session's placement note: information, not an action (no focus, no hover). */
.tab-rail-action-menu .tab-rail-action-menu-note {
padding: 0.45rem 0.6rem;
color: var(--text-muted);
font-size: 0.85em;
cursor: default;
}
: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,
+12
View File
@@ -336,6 +336,18 @@ Object.assign(CodemanApp.prototype, {
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
];
for (const action of actions) {
// A note (a child session's placement) is plain text, not a menu item: it
// takes no focus and no arrow stop, and describes the menu instead.
if (action.note) {
const note = document.createElement('div');
note.className = 'tab-rail-action-menu-note';
note.setAttribute('role', 'none');
note.id = 'tabRailActionMenuNote';
note.textContent = action.label;
menu.setAttribute('aria-describedby', note.id);
menu.appendChild(note);
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.setAttribute('role', 'menuitem');
+9 -3
View File
@@ -1277,7 +1277,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);
@@ -1295,7 +1297,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 ==========
@@ -3618,6 +3622,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
@@ -4272,7 +4277,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);
@@ -4358,6 +4363,7 @@ export function registerSessionRoutes(
casePath: resolvedCasePath,
caseName,
...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}),
...(tabGroupId ? { tabLayout } : {}),
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
+21
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,
@@ -1108,6 +1119,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
@@ -1376,6 +1389,14 @@ export const SettingsUpdateSchema = z
* broadcast and /api/search, which is the user's choice to make.
*/
autoNameSessions: z.boolean().optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
* hand. SYNCED, default OFF, because the server does the placement:
* TabLayoutService.sessionCreated() reads it at every creation, and with it
* off the child lands after its parent once and is hand-placed from then on.
*/
spawnedTabsFollowParent: z.boolean().optional(),
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
+18 -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';
@@ -463,6 +464,9 @@ export class WebServer extends EventEmitter {
this.cachedLightState = null;
this.sse.broadcastSessionOrder(change);
},
// Fresh, not the 2s cache: the settings PUT does not invalidate it, and a
// flip must apply to the very next spawn.
childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true,
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
@@ -715,15 +719,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);
};
});
}
/**