test layout-utils, and stop setApp leaking one app's config to the next
47 tests, the first under any Workspace path. These functions carry a panel's
identity now, so a regression in swapPanels is two agents exchanging names,
not a cosmetic glitch.
Writing them found one: setApp preserved config whenever appType was not null,
so changing a panel from chat to terminal handed the terminal the chat's
{agentName} to read as its own settings. The comment beside it already stated
the opposite intent. Not reachable through the UI today — the picker only
appears on an empty panel, so the only route out of an app is via null, which
does clear it — but setApp is exported and its signature permits the direct
swap. Now only a same-app set keeps the config.
Also pins two things as expectations rather than folklore: a move drops zoom
and fitContent (todo 5.3, to fail the day that is fixed), and a split
redistributes sibling sizes evenly (todo 5.5).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { LayoutGroup, LayoutNode, LayoutPanel } from './types';
|
||||
import {
|
||||
clampZoom,
|
||||
collectPanelConfigs,
|
||||
countPanels,
|
||||
hasAnyApp,
|
||||
movePanel,
|
||||
pruneEmptyPanels,
|
||||
removePanel,
|
||||
setApp,
|
||||
setPanelConfig,
|
||||
setZoom,
|
||||
splitPanel,
|
||||
swapPanels,
|
||||
updateSizes,
|
||||
} from './layout-utils';
|
||||
|
||||
// These functions are the whole of the layout's behaviour, and since `e588524` they also carry a panel's
|
||||
// *identity* — the opaque `config` an app uses to remember what it is. `apps/Chat` stores an agent's name
|
||||
// there, so a regression in `swapPanels` here is two agents exchanging identities, not a cosmetic glitch.
|
||||
// Everything below is a pure function over a serialisable tree; there is nothing to mock.
|
||||
|
||||
const panel = (id: string, appType: string | null = null, extra: Partial<LayoutPanel> = {}): LayoutPanel => ({
|
||||
type: 'panel',
|
||||
id,
|
||||
appType,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const group = (id: string, direction: 'horizontal' | 'vertical', children: LayoutNode[]): LayoutGroup => ({
|
||||
type: 'group',
|
||||
id,
|
||||
direction,
|
||||
children: children.map((node) => ({ node, size: 100 / children.length })),
|
||||
});
|
||||
|
||||
/** Find a panel by id anywhere in the tree — the assertion helper, deliberately not the module's own. */
|
||||
const find = (node: LayoutNode, id: string): LayoutPanel | undefined => {
|
||||
if (node.type === 'panel') return node.id === id ? node : undefined;
|
||||
for (const child of node.children) {
|
||||
const hit = find(child.node, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const ids = (node: LayoutNode): string[] =>
|
||||
node.type === 'panel' ? [node.id] : node.children.flatMap((c) => ids(c.node));
|
||||
|
||||
describe('countPanels / hasAnyApp', () => {
|
||||
test('counts leaves at any depth', () => {
|
||||
expect(countPanels(panel('a'))).toBe(1);
|
||||
expect(countPanels(group('g', 'horizontal', [panel('a'), group('h', 'vertical', [panel('b'), panel('c')])]))).toBe(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasAnyApp is false only when every panel is empty', () => {
|
||||
expect(hasAnyApp(group('g', 'horizontal', [panel('a'), panel('b')]))).toBe(false);
|
||||
expect(hasAnyApp(group('g', 'horizontal', [panel('a'), panel('b', 'x')]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitPanel', () => {
|
||||
test('appends a sibling when the direction matches the parent, keeping the original node by reference', () => {
|
||||
const a = panel('a', 'x');
|
||||
const root = group('g', 'horizontal', [a, panel('b')]);
|
||||
|
||||
const next = splitPanel(root, 'a', 'horizontal') as LayoutGroup;
|
||||
|
||||
expect(next.id).toBe('g');
|
||||
expect(next.children).toHaveLength(3);
|
||||
// The original node object survives — this is what keeps the panel's React subtree mounted.
|
||||
expect(next.children[0]!.node).toBe(a);
|
||||
expect(next.children.map((c) => c.size)).toEqual([100 / 3, 100 / 3, 100 / 3]);
|
||||
});
|
||||
|
||||
test('wraps in a new group when the direction differs', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b')]);
|
||||
|
||||
const next = splitPanel(root, 'a', 'vertical') as LayoutGroup;
|
||||
const wrapper = next.children[0]!.node as LayoutGroup;
|
||||
|
||||
expect(wrapper.type).toBe('group');
|
||||
expect(wrapper.direction).toBe('vertical');
|
||||
expect(wrapper.children[0]!.node).toEqual(panel('a', 'x'));
|
||||
// The wrapper is a new node with a fresh id, which is why this case remounts the panel (todo §5.2).
|
||||
expect(wrapper.id).not.toBe('a');
|
||||
});
|
||||
|
||||
test('splitting a root panel replaces the root with a group', () => {
|
||||
const next = splitPanel(panel('a', 'x'), 'a', 'horizontal') as LayoutGroup;
|
||||
|
||||
expect(next.type).toBe('group');
|
||||
expect(countPanels(next)).toBe(2);
|
||||
});
|
||||
|
||||
test('an unknown panel id leaves the tree untouched by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
|
||||
expect(splitPanel(root, 'nope', 'horizontal')).toBe(root);
|
||||
});
|
||||
|
||||
test('sizes are redistributed evenly, discarding tuned proportions', () => {
|
||||
// Documenting current behaviour, not endorsing it — this is todo §5.5 "preserve sibling sizes".
|
||||
const root: LayoutGroup = {
|
||||
type: 'group',
|
||||
id: 'g',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: panel('a'), size: 80 },
|
||||
{ node: panel('b'), size: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
const next = splitPanel(root, 'a', 'horizontal') as LayoutGroup;
|
||||
expect(next.children.every((c) => c.size === 100 / 3)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePanel', () => {
|
||||
test('refuses to remove the last panel', () => {
|
||||
const root = panel('a', 'x');
|
||||
expect(removePanel(root, 'a')).toBe(root);
|
||||
|
||||
const wrapped = group('g', 'horizontal', [panel('a', 'x')]);
|
||||
expect(removePanel(wrapped, 'a')).toBe(wrapped);
|
||||
});
|
||||
|
||||
test('collapses a two-child group to the survivor, which keeps its own id and config', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b', 'y', { config: { agentName: 'backend' } })]);
|
||||
|
||||
const next = removePanel(root, 'a');
|
||||
|
||||
// The group is gone and the survivor is promoted — this is the remount in todo §5.2, row 4.
|
||||
expect(next.type).toBe('panel');
|
||||
expect((next as LayoutPanel).id).toBe('b');
|
||||
expect((next as LayoutPanel).config).toEqual({ agentName: 'backend' });
|
||||
});
|
||||
|
||||
test('renormalises sizes to 100 when three become two', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b'), panel('c')]);
|
||||
|
||||
const next = removePanel(root, 'b') as LayoutGroup;
|
||||
|
||||
expect(ids(next)).toEqual(['a', 'c']);
|
||||
expect(next.children.reduce((sum, c) => sum + c.size, 0)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
test('removes from a nested group', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), group('h', 'vertical', [panel('b'), panel('c')])]);
|
||||
|
||||
expect(ids(removePanel(root, 'c'))).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('unwraps a group left holding a single child', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
group('h', 'vertical', [group('k', 'horizontal', [panel('p1', 'x'), panel('p2', 'y')])]),
|
||||
panel('b', 'z'),
|
||||
]);
|
||||
|
||||
const next = removePanel(root, 'p1') as LayoutGroup;
|
||||
|
||||
expect(ids(next)).toEqual(['p2', 'b']);
|
||||
// Both `h` and `k` collapsed away rather than leaving one-child groups behind.
|
||||
expect(next.children[0]!.node.type).toBe('panel');
|
||||
});
|
||||
|
||||
test('a group emptied entirely becomes a fresh empty panel', () => {
|
||||
const root = group('g', 'horizontal', [group('h', 'vertical', [panel('a', 'x')]), panel('b', 'y')]);
|
||||
|
||||
const next = removePanel(root, 'a') as LayoutGroup;
|
||||
|
||||
expect(countPanels(next)).toBe(2);
|
||||
expect((next.children[0]!.node as LayoutPanel).appType).toBeNull();
|
||||
expect(ids(next)[1]).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApp', () => {
|
||||
test('sets the app type in place', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
|
||||
|
||||
expect(find(setApp(root, 'a', 'officerdev/chat'), 'a')!.appType).toBe('officerdev/chat');
|
||||
});
|
||||
|
||||
test('clearing the app drops the config with it', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } })]);
|
||||
|
||||
const cleared = find(setApp(root, 'a', null), 'a')!;
|
||||
|
||||
expect(cleared.appType).toBeNull();
|
||||
expect('config' in cleared).toBe(false);
|
||||
});
|
||||
|
||||
test('changing to a DIFFERENT app drops the config too', () => {
|
||||
// The config belongs to whichever app is running. A terminal must never be handed a chat panel's
|
||||
// `{agentName}` and read it as its own settings. Only reachable through the UI as app → null → app
|
||||
// today, but `setApp` is exported and the signature permits a direct swap.
|
||||
const root = group('g', 'horizontal', [panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } })]);
|
||||
|
||||
const changed = find(setApp(root, 'a', 'officerdev/terminal'), 'a')!;
|
||||
|
||||
expect(changed.appType).toBe('officerdev/terminal');
|
||||
expect('config' in changed).toBe(false);
|
||||
});
|
||||
|
||||
test('re-setting the SAME app keeps the config', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } })]);
|
||||
|
||||
expect(find(setApp(root, 'a', 'officerdev/chat'), 'a')!.config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
|
||||
test('an unknown panel id leaves the tree untouched by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
|
||||
expect(setApp(root, 'nope', 'x')).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPanelConfig / collectPanelConfigs', () => {
|
||||
test('writes, then replaces wholesale rather than merging', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'officerdev/chat')]);
|
||||
|
||||
const named = setPanelConfig(root, 'a', { agentName: 'frontend' });
|
||||
expect(find(named, 'a')!.config).toEqual({ agentName: 'frontend' });
|
||||
|
||||
const replaced = setPanelConfig(named, 'a', { other: 1 });
|
||||
expect(find(replaced, 'a')!.config).toEqual({ other: 1 });
|
||||
});
|
||||
|
||||
test('undefined and {} both remove the key, so an unconfigured panel adds nothing to the layout', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { config: { agentName: 'frontend' } })]);
|
||||
|
||||
expect('config' in find(setPanelConfig(root, 'a', undefined), 'a')!).toBe(false);
|
||||
expect('config' in find(setPanelConfig(root, 'a', {}), 'a')!).toBe(false);
|
||||
});
|
||||
|
||||
test('setting a config never disturbs the app type or the panel id', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'officerdev/chat'), panel('b', 'officerdev/terminal')]);
|
||||
|
||||
const next = setPanelConfig(root, 'a', { agentName: 'frontend' });
|
||||
|
||||
expect(find(next, 'a')!.appType).toBe('officerdev/chat');
|
||||
expect(find(next, 'b')).toEqual(panel('b', 'officerdev/terminal'));
|
||||
expect(ids(next)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('an unknown panel id leaves the tree untouched by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
|
||||
expect(setPanelConfig(root, 'nope', { x: 1 })).toBe(root);
|
||||
});
|
||||
|
||||
test('collects only configured panels, at any depth', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'x', { config: { agentName: 'frontend' } }),
|
||||
group('h', 'vertical', [panel('b'), panel('c', 'y', { config: { agentName: 'backend' } })]),
|
||||
]);
|
||||
|
||||
expect(collectPanelConfigs(root)).toEqual({
|
||||
a: { agentName: 'frontend' },
|
||||
c: { agentName: 'backend' },
|
||||
});
|
||||
});
|
||||
|
||||
test('collects nothing from a tree with no configs', () => {
|
||||
expect(collectPanelConfigs(group('g', 'horizontal', [panel('a', 'x'), panel('b')]))).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('swapPanels', () => {
|
||||
test('exchanges appType and config together, leaving ids where they are', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b', 'officerdev/chat', { config: { agentName: 'backend' } }),
|
||||
]);
|
||||
|
||||
const next = swapPanels(root, 'a', 'b');
|
||||
|
||||
// Ids are positions in the tree; contents move between them.
|
||||
expect(ids(next)).toEqual(['a', 'b']);
|
||||
expect(find(next, 'a')!.config).toEqual({ agentName: 'backend' });
|
||||
expect(find(next, 'b')!.config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
|
||||
test('a config never survives in the panel it came from', () => {
|
||||
// The regression that would matter most: two agents both answering to the same name.
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b', 'officerdev/terminal'),
|
||||
]);
|
||||
|
||||
const next = swapPanels(root, 'a', 'b');
|
||||
|
||||
expect('config' in find(next, 'a')!).toBe(false);
|
||||
expect(find(next, 'a')!.appType).toBe('officerdev/terminal');
|
||||
expect(find(next, 'b')!.config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
|
||||
test('swaps across different branches of the tree', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'x', { config: { n: 1 } }),
|
||||
group('h', 'vertical', [panel('b'), panel('c', 'y', { config: { n: 2 } })]),
|
||||
]);
|
||||
|
||||
const next = swapPanels(root, 'a', 'c');
|
||||
|
||||
expect(find(next, 'a')!.config).toEqual({ n: 2 });
|
||||
expect(find(next, 'c')!.config).toEqual({ n: 1 });
|
||||
});
|
||||
|
||||
test('an unknown id on either side leaves the tree untouched by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b')]);
|
||||
expect(swapPanels(root, 'a', 'nope')).toBe(root);
|
||||
expect(swapPanels(root, 'nope', 'b')).toBe(root);
|
||||
});
|
||||
|
||||
test('does not mutate the input tree', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { config: { n: 1 } }), panel('b', 'y')]);
|
||||
const before = structuredClone(root);
|
||||
|
||||
swapPanels(root, 'a', 'b');
|
||||
|
||||
expect(root).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('movePanel', () => {
|
||||
test("carries the panel's config to its new position", () => {
|
||||
// The drag path is dead code today (todo §5.3), but this is the invariant that disarmed it as a
|
||||
// hazard: an agent's name travels with the panel rather than being reset to defaults.
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b', 'officerdev/terminal'),
|
||||
panel('c'),
|
||||
]);
|
||||
|
||||
const next = movePanel(root, 'a', 'c', 'right');
|
||||
|
||||
// Exactly one panel carries the config, and it is a different node id than before.
|
||||
const configs = Object.entries(collectPanelConfigs(next));
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]![1]).toEqual({ agentName: 'frontend' });
|
||||
expect(configs[0]![0]).not.toBe('a');
|
||||
});
|
||||
|
||||
test('center position delegates to a swap', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'x', { config: { n: 1 } }),
|
||||
panel('b', 'y', { config: { n: 2 } }),
|
||||
]);
|
||||
|
||||
expect(movePanel(root, 'a', 'b', 'center')).toEqual(swapPanels(root, 'a', 'b'));
|
||||
});
|
||||
|
||||
test('moving a panel onto itself is a no-op by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b')]);
|
||||
expect(movePanel(root, 'a', 'a', 'right')).toBe(root);
|
||||
});
|
||||
|
||||
test('an unknown source leaves the tree untouched by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b')]);
|
||||
expect(movePanel(root, 'nope', 'a', 'right')).toBe(root);
|
||||
});
|
||||
|
||||
test('wraps the target in a new group when the drop direction differs from its parent', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b', 'x'),
|
||||
panel('c', 'y'),
|
||||
]);
|
||||
|
||||
const next = movePanel(root, 'a', 'c', 'top') as LayoutGroup;
|
||||
const wrapper = next.children[1]!.node as LayoutGroup;
|
||||
|
||||
expect(wrapper.type).toBe('group');
|
||||
expect(wrapper.direction).toBe('vertical');
|
||||
// The moved contents land above the target, config intact.
|
||||
expect((wrapper.children[0]!.node as LayoutPanel).config).toEqual({ agentName: 'frontend' });
|
||||
expect((wrapper.children[1]!.node as LayoutPanel).id).toBe('c');
|
||||
});
|
||||
|
||||
test('wraps below the target when dropping bottom', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b', 'x'),
|
||||
panel('c', 'y'),
|
||||
]);
|
||||
|
||||
const wrapper = (movePanel(root, 'a', 'c', 'bottom') as LayoutGroup).children[1]!.node as LayoutGroup;
|
||||
|
||||
expect((wrapper.children[0]!.node as LayoutPanel).id).toBe('c');
|
||||
expect((wrapper.children[1]!.node as LayoutPanel).config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
|
||||
test('inserts before the target when dropping left', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { config: { n: 1 } }), panel('b'), panel('c')]);
|
||||
|
||||
const next = movePanel(root, 'a', 'c', 'left') as LayoutGroup;
|
||||
const order = ids(next);
|
||||
|
||||
expect(order).toHaveLength(3);
|
||||
// The moved panel sits immediately before `c`.
|
||||
expect(order[order.indexOf('c') - 1]).toBe(Object.keys(collectPanelConfigs(next))[0]);
|
||||
});
|
||||
|
||||
test('KNOWN GAP: a move drops zoom and fitContent', () => {
|
||||
// `PanelContents` carries only `{appType, config}`. Recorded in todo §5.3 as a thing to fix before
|
||||
// the drag UI is ever re-enabled; pinned here so the gap is a failing expectation the day it closes,
|
||||
// not a silent behaviour change.
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'x', { zoom: 1.3, fitContent: true, config: { n: 1 } }),
|
||||
panel('b'),
|
||||
panel('c'),
|
||||
]);
|
||||
|
||||
const next = movePanel(root, 'a', 'c', 'right');
|
||||
const moved = Object.keys(collectPanelConfigs(next))[0]!;
|
||||
|
||||
expect(find(next, moved)!.zoom).toBeUndefined();
|
||||
expect(find(next, moved)!.fitContent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setZoom / clampZoom', () => {
|
||||
test('clamps to the declared range and snaps to one decimal', () => {
|
||||
expect(clampZoom(0.1)).toBe(0.7);
|
||||
expect(clampZoom(9)).toBe(1.6);
|
||||
expect(clampZoom(1.24)).toBe(1.2);
|
||||
});
|
||||
|
||||
test('stores nothing at 1, so an untouched panel adds no key', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { zoom: 1.3 })]);
|
||||
|
||||
expect('zoom' in find(setZoom(root, 'a', 1), 'a')!).toBe(false);
|
||||
});
|
||||
|
||||
test('a no-op zoom returns the tree by reference', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { zoom: 1.3 })]);
|
||||
expect(setZoom(root, 'a', 1.3)).toBe(root);
|
||||
expect(setZoom(root, 'nope', 1.5)).toBe(root);
|
||||
});
|
||||
|
||||
test('zoom does not disturb config', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x', { config: { agentName: 'frontend' } })]);
|
||||
|
||||
expect(find(setZoom(root, 'a', 1.4), 'a')!.config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSizes', () => {
|
||||
test('applies sizes to the named group only', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), group('h', 'vertical', [panel('b'), panel('c')])]);
|
||||
|
||||
const next = updateSizes(root, 'h', [70, 30]) as LayoutGroup;
|
||||
const inner = next.children[1]!.node as LayoutGroup;
|
||||
|
||||
expect(inner.children.map((c) => c.size)).toEqual([70, 30]);
|
||||
expect(next.children.map((c) => c.size)).toEqual([50, 50]);
|
||||
});
|
||||
|
||||
test('a short sizes array leaves the remaining children alone', () => {
|
||||
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
|
||||
|
||||
expect((updateSizes(root, 'g', [70]) as LayoutGroup).children.map((c) => c.size)).toEqual([70, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneEmptyPanels', () => {
|
||||
test('drops empty panels and collapses what is left', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b'), panel('c')]);
|
||||
|
||||
const next = pruneEmptyPanels(root)!;
|
||||
|
||||
expect(next.type).toBe('panel');
|
||||
expect((next as LayoutPanel).id).toBe('a');
|
||||
});
|
||||
|
||||
test('returns null when nothing has an app', () => {
|
||||
expect(pruneEmptyPanels(group('g', 'horizontal', [panel('a'), panel('b')]))).toBeNull();
|
||||
expect(pruneEmptyPanels(panel('a'))).toBeNull();
|
||||
});
|
||||
|
||||
test('renormalises sizes of the survivors', () => {
|
||||
const root = group('g', 'horizontal', [panel('a', 'x'), panel('b', 'y'), panel('c')]);
|
||||
|
||||
const next = pruneEmptyPanels(root) as LayoutGroup;
|
||||
|
||||
expect(next.children.reduce((sum, c) => sum + c.size, 0)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
test('keeps a configured panel that has an app', () => {
|
||||
const root = group('g', 'horizontal', [
|
||||
panel('a', 'officerdev/chat', { config: { agentName: 'frontend' } }),
|
||||
panel('b'),
|
||||
]);
|
||||
|
||||
expect((pruneEmptyPanels(root) as LayoutPanel).config).toEqual({ agentName: 'frontend' });
|
||||
});
|
||||
});
|
||||
@@ -236,9 +236,12 @@ export function swapPanels(root: LayoutNode, idA: string, idB: string): LayoutNo
|
||||
}
|
||||
|
||||
export function setApp(root: LayoutNode, panelId: string, appType: string | null): LayoutNode {
|
||||
// Clearing the app clears its config with it — the next app to occupy the panel would not understand
|
||||
// the old one's settings, and leaving them would resurrect them if the same app came back later.
|
||||
return setContents(root, panelId, appType === null ? { appType: null } : { appType, config: findPanelContents(root, panelId)?.config });
|
||||
// Changing the app clears its config with it — config is owned by whichever app the panel is running,
|
||||
// and the next occupant would read the previous one's settings as its own. Only a no-op set (the same
|
||||
// app, e.g. re-picking what is already there) keeps them.
|
||||
const current = findPanelContents(root, panelId);
|
||||
if (!current) return root;
|
||||
return setContents(root, panelId, { appType, config: appType === current.appType ? current.config : undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user