fix(tabs): put child follow behind a setting, stop unpinning children on order PUTs

Review fixes for "place new, child and web tabs into groups".

- Spawned tabs follow their parent only with the new synced setting
  spawnedTabsFollowParent (App Settings > Appearance > Tabs, off by default).
  TabLayoutService.sessionCreated() reads it fresh through a
  childrenFollowParent dep: off, a spawned session lands after its parent and
  is marked manual in the same write; on, it follows until moved by hand. The
  row menu shows the placement note and "Follow parent again" only when on.
  "New session" in a group menu is unaffected. zh-CN label and a
  Settings-Reference row.
- applyLegacySessionRank marks a child manual only when the requested order
  splits it from its parent's block. A PUT of the current order, or one that
  moves a family together, leaves every stored placement alone.
- The browser never echoes a create or delete through PUT /api/session-order,
  with or without groups: the layout service already re-projects the order and
  broadcasts it, and syncSessionOrder adopts it.
- The placement note in the row menu is plain text described by the menu, not
  a focusable menu item.
- A tab that first appears inside a collapsed group briefly marks that group's
  header.
- i18n skips .tab-name, so tab names are never translated.
- Tests: legacy-order cases for an unchanged order, a family reorder and a
  split; service cases for the setting and for order PUTs; the scheduled
  registration test now exercises register/undo; the grouped-create test binds
  port 0; one route case runs through the real layout service.
This commit is contained in:
Aamer Akhter committed 2026-10-09 20:34:37 -04:00
1 parent b8ccfc33d1
commit 5fb56b0889
21 files changed
+803 -118

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[],
+39 -6
View File
@@ -13,6 +13,7 @@ import {
materializeOrphans,
moveRef,
normalizeTabLayout,
setManualPlacement,
TabLayoutValidationError,
validateTabLayout,
type TabLayout,
@@ -43,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;
}
@@ -110,6 +117,23 @@ 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,
@@ -487,6 +511,12 @@ export class TabLayoutService {
* 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,
@@ -495,15 +525,18 @@ export class TabLayoutService {
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;
return await this.getUnlocked(
owner,
sessionId && groupId
? (layout, metadata) => placeCreatedSession(layout, sessionId, groupId, metadata)
: undefined
);
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;
+49 -27
View File
@@ -703,6 +703,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();
@@ -2288,16 +2291,12 @@ class CodemanApp {
_onSessionCreated(data) {
this.sessions.set(data.id, data);
if (!this.sessionOrder.includes(data.id)) {
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();
}
// 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.
@@ -6787,6 +6786,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);
@@ -7059,6 +7059,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;
@@ -7428,18 +7460,6 @@ 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 = {};
@@ -7616,13 +7636,16 @@ class CodemanApp {
}
/**
* 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
* 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 [];
@@ -7634,7 +7657,7 @@ class CodemanApp {
dangling: 'Parent closed; placed on its own',
cycle: 'Parent loop; placed on its own',
}[placement.state];
const actions = [{ label: summary, disabled: true }];
const actions = [{ label: summary, note: true }];
if (placement.canFollow) {
actions.push({
label: 'Follow parent again',
@@ -9658,9 +9681,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);
// With groups, the server's deletion already re-projected the order.
if (!this._serverPlacesSessions()) this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.terminalBufferCache.delete(sessionId);
+5
View File
@@ -25,6 +25,8 @@
'.response-viewer-content',
'.file-preview-content',
'.session-tab-name',
// Tab names are user text: the strip, rail and sidebar draw them in .tab-name.
'.tab-name',
'.session-name',
'.case-name',
'.notif-item-message',
@@ -430,6 +432,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': '没有待处理的审批',
+7
View File
@@ -2237,6 +2237,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>
+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;
@@ -2551,6 +2553,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,
+16 -6
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;
@@ -2909,17 +2922,14 @@ 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 {
/* 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;
}
.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;
+12 -6
View File
@@ -336,18 +336,24 @@ 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');
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();
});
+8
View File
@@ -1361,6 +1361,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:
+3
View File
@@ -457,6 +457,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();