fix(tabs): children spawned from one parent keep creation order (#528 merge-time fix)

With the follow setting off (the default), pinCreatedChild marked each new
child manual where normalization put it: right after the parent's FOLLOWING
block, which no longer holds the siblings pinned before it. A fan-out
therefore showed parent, w3, w2, w1 in the layout and the global
sessionOrder. The pinned child now steps past the siblings (and their
descendants) already after the parent.

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:49 +02:00
1 parent a38eab49cd
commit 5ac27f3eec
2 files changed
+44 -1

No files matched your search

+25 -1
View File
@@ -131,7 +131,31 @@ function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonl
const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId); const parentStored = refs.some((candidate) => candidate.kind === 'session' && candidate.id === parentId);
const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid); const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid);
if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout; if (!ref || ref.placement === 'manual' || !parentStored || !parentValid) return layout;
return setManualPlacement(layout, ref, true); const pinned = setManualPlacement(layout, ref, true);
// Normalization put the child right after its parent's FOLLOWING block, i.e.
// ahead of the siblings pinned before it. Step past those (and their
// descendants) so a fan-out keeps creation order.
const parentOf = new Map<string, string>();
for (const fact of metadata) {
if (fact.kind === 'session' && fact.ownerValid && fact.parentSessionId) parentOf.set(fact.id, fact.parentSessionId);
}
const descendsFromParent = (id: string): boolean => {
let current = parentOf.get(id);
for (let hops = 0; current !== undefined && hops <= parentOf.size; hops++) {
if (current === parentId) return true;
current = parentOf.get(current);
}
return false;
};
const isChild = (candidate: TabRef): boolean => candidate.kind === 'session' && candidate.id === sessionId;
const group = pinned.groups.find((candidate) => candidate.refs.some(isChild));
const container = group ? group.refs : pinned.ungrouped;
const from = container.findIndex(isChild);
let to = from;
while (to + 1 < container.length && container[to + 1].kind === 'session' && descendsFromParent(container[to + 1].id))
to++;
if (to === from) return pinned;
return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata);
} }
/** Move a just-created session to the end of `groupId`, or leave the layout alone. */ /** Move a just-created session to the end of `groupId`, or leave the layout alone. */
+19
View File
@@ -1438,6 +1438,25 @@ describe('TabLayoutService grouped session creation', () => {
expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']); expect(moved.layout.ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']);
}); });
it('with the follow setting off, children spawned one after another keep creation order', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
let layout = grouped();
for (const [index, id] of ['c1', 'c2', 'c3'].entries()) {
layout = await h.service.sessionCreated(
'alice',
id,
{},
register(h, { id, owner: 'alice', createdAt: 3 + index, parentSessionId: 'a' })
);
}
expect(layout.groups[0].refs).toEqual([
{ kind: 'session', id: 'a' },
{ kind: 'session', id: 'c1', placement: 'manual' },
{ kind: 'session', id: 'c2', placement: 'manual' },
{ kind: 'session', id: 'c3', placement: 'manual' },
]);
});
it('reads the follow setting fresh at every creation', async () => { it('reads the follow setting fresh at every creation', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
const setting = vi.fn<() => boolean>().mockReturnValueOnce(true).mockReturnValueOnce(false); const setting = vi.fn<() => boolean>().mockReturnValueOnce(true).mockReturnValueOnce(false);