mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
b59145effd
commit
b8ccfc33d1
22 files changed
+1227
-57
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}”?其中的标签将移到未分组。`,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user