feat(layouts): Phase 1 - workspace layout configuration system

Add per-workspace layout configuration with smart auto-balancing:

**Core Changes:**
- Add LayoutConfig interface to Workspace type with insertionMode, splitPercentage, insertionPosition
- Create layout-utils.ts with smart direction algorithm that auto-balances horizontal/vertical splits
- Update addWindow() to use workspace layoutConfig instead of hardcoded values
- Migrate state from v7→v8, adding layoutConfig to all workspaces with smart defaults

**Smart Direction Algorithm:**
- Analyzes layout tree to count horizontal vs vertical splits
- Automatically balances by favoring the less-common direction
- Defaults to horizontal (row) for first split
- Provides foundation for "Balanced (auto)" insertion mode

**Testing:**
- Add 30 comprehensive tests for layout-utils.ts (tree analysis, smart direction, window insertion)
- Add 30 tests for logic.ts addWindow() with different layout configs (row/column/smart modes)
- Update migration tests to verify v6→v7→v8 and v7→v8 paths

**Migration:**
- v7→v8 adds layoutConfig with defaults: smart mode, 50% split, second position
- All existing workspaces automatically get smart auto-balancing behavior

Implements orthogonal design: layout behavior = workspace settings (not command flags)
This commit is contained in:
Alejandro Gómez
2025-12-18 11:59:45 +01:00
parent 727c38d2ef
commit cc6f8d646b
8 changed files with 1256 additions and 18 deletions

View File

@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { findLowestAvailableWorkspaceNumber } from "./logic";
import type { MosaicNode } from "react-mosaic-component";
import { findLowestAvailableWorkspaceNumber, addWindow } from "./logic";
import type { GrimoireState, LayoutConfig } from "@/types/app";
describe("findLowestAvailableWorkspaceNumber", () => {
describe("basic number assignment", () => {
@@ -125,3 +127,408 @@ describe("findLowestAvailableWorkspaceNumber", () => {
});
});
});
describe("addWindow", () => {
// Helper to create minimal test state
const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode<string> | null = null): GrimoireState => ({
__version: 8,
windows: {},
activeWorkspaceId: "test-workspace",
workspaces: {
"test-workspace": {
id: "test-workspace",
number: 1,
windowIds: [],
layout: existingLayout,
layoutConfig,
},
},
});
describe("first window", () => {
it("should create first window with row config", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "profile",
props: { npub: "test" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.windowIds).toHaveLength(1);
expect(workspace.layout).toBe(workspace.windowIds[0]); // Single window = leaf node
expect(result.windows[workspace.windowIds[0]]).toEqual({
id: workspace.windowIds[0],
appId: "profile",
customTitle: undefined,
props: { npub: "test" },
commandString: undefined,
});
});
it("should create first window with column config", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.windowIds).toHaveLength(1);
expect(workspace.layout).toBe(workspace.windowIds[0]);
});
it("should create first window with smart config", () => {
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "kinds",
props: {},
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.windowIds).toHaveLength(1);
expect(workspace.layout).toBe(workspace.windowIds[0]);
});
});
describe("second window with row config", () => {
it("should create horizontal split", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.windowIds).toHaveLength(2);
expect(workspace.layout).toMatchObject({
direction: "row",
splitPercentage: 50,
});
});
it("should respect custom split percentage", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.layout).toMatchObject({
direction: "row",
splitPercentage: 70,
});
});
it("should place new window on right when position is second", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
const layout = workspace.layout as any;
expect(layout.first).toBe("window-1");
expect(layout.second).toBe(workspace.windowIds[1]);
});
it("should place new window on left when position is first", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "first",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
const layout = workspace.layout as any;
expect(layout.first).toBe(workspace.windowIds[1]); // New window
expect(layout.second).toBe("window-1"); // Old window
});
});
describe("second window with column config", () => {
it("should create vertical split", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.windowIds).toHaveLength(2);
expect(workspace.layout).toMatchObject({
direction: "column",
splitPercentage: 50,
});
});
it("should place new window on bottom when position is second", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
const layout = workspace.layout as any;
expect(layout.first).toBe("window-1");
expect(layout.second).toBe(workspace.windowIds[1]);
});
it("should place new window on top when position is first", () => {
const state = createTestState({
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "first",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
const layout = workspace.layout as any;
expect(layout.first).toBe(workspace.windowIds[1]); // New window
expect(layout.second).toBe("window-1"); // Old window
});
});
describe("second window with smart config", () => {
it("should create horizontal split for first split", () => {
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
const workspace = result.workspaces["test-workspace"];
expect(workspace.layout).toMatchObject({
direction: "row", // Smart defaults to row for first split
});
});
});
describe("third window with smart config", () => {
it("should balance by adding vertical split when horizontal exists", () => {
// Start with horizontal split (window-1 | window-2)
const existingLayout: MosaicNode<string> = {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, existingLayout);
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
const result = addWindow(state, {
appId: "kinds",
props: {},
});
const workspace = result.workspaces["test-workspace"];
// Should add column split to balance (1 row, 0 column → add column)
expect(workspace.layout).toMatchObject({
direction: "column",
});
});
it("should balance by adding horizontal split when vertical exists", () => {
// Start with vertical split (window-1 / window-2)
const existingLayout: MosaicNode<string> = {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const state = createTestState({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
}, existingLayout);
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
const result = addWindow(state, {
appId: "kinds",
props: {},
});
const workspace = result.workspaces["test-workspace"];
// Should add row split to balance (0 row, 1 column → add row)
expect(workspace.layout).toMatchObject({
direction: "row",
});
});
});
describe("window metadata", () => {
it("should store commandString when provided", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "profile",
props: { npub: "test" },
commandString: "profile alice@nostr.com",
});
const workspace = result.workspaces["test-workspace"];
const window = result.windows[workspace.windowIds[0]];
expect(window.commandString).toBe("profile alice@nostr.com");
});
it("should store customTitle when provided", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "profile",
props: { npub: "test" },
customTitle: "Alice Profile",
});
const workspace = result.workspaces["test-workspace"];
const window = result.windows[workspace.windowIds[0]];
expect(window.customTitle).toBe("Alice Profile");
});
it("should store both commandString and customTitle", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "profile",
props: { npub: "test" },
commandString: "profile alice@nostr.com",
customTitle: "Alice",
});
const workspace = result.workspaces["test-workspace"];
const window = result.windows[workspace.windowIds[0]];
expect(window.commandString).toBe("profile alice@nostr.com");
expect(window.customTitle).toBe("Alice");
});
});
describe("global windows object", () => {
it("should add window to global windows object", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
});
const result = addWindow(state, {
appId: "profile",
props: { npub: "test" },
});
const workspace = result.workspaces["test-workspace"];
const windowId = workspace.windowIds[0];
expect(result.windows[windowId]).toBeDefined();
expect(result.windows[windowId].appId).toBe("profile");
});
it("should preserve existing windows when adding new one", () => {
const state = createTestState({
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
}, "window-1");
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
state.workspaces["test-workspace"].windowIds = ["window-1"];
const result = addWindow(state, {
appId: "nip",
props: { number: "01" },
});
expect(result.windows["window-1"]).toBeDefined();
expect(Object.keys(result.windows)).toHaveLength(2);
});
});
});

View File

@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import type { MosaicNode } from "react-mosaic-component";
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
/**
* Finds the lowest available workspace number.
@@ -74,18 +75,8 @@ export const addWindow = (
commandString: payload.commandString,
};
// Simple Binary Split Logic
let newLayout: MosaicNode<string>;
if (ws.layout === null) {
newLayout = newWindowId;
} else {
newLayout = {
direction: "row",
first: ws.layout,
second: newWindowId,
splitPercentage: 50,
};
}
// Insert window using workspace layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
return {
...state,

View File

@@ -18,6 +18,12 @@ const initialState: GrimoireState = {
number: 1,
windowIds: [],
layout: null,
layoutConfig: {
insertionMode: "smart", // Smart auto-balancing by default
splitPercentage: 50, // Equal split
insertionPosition: "second", // New windows on right/bottom
autoPreset: undefined, // No preset maintenance
},
},
},
};

View File

@@ -0,0 +1,557 @@
import { describe, it, expect } from "vitest";
import type { MosaicNode } from "react-mosaic-component";
import {
analyzeLayoutStats,
calculateSmartDirection,
insertWindow,
type LayoutStats,
} from "./layout-utils";
import type { LayoutConfig } from "@/types/app";
describe("analyzeLayoutStats", () => {
describe("empty and single window layouts", () => {
it("should return zeros for null layout", () => {
const result = analyzeLayoutStats(null);
expect(result).toEqual({
rowSplits: 0,
columnSplits: 0,
depth: 0,
windowCount: 0,
});
});
it("should return single window stats for leaf node", () => {
const result = analyzeLayoutStats("window-1");
expect(result).toEqual({
rowSplits: 0,
columnSplits: 0,
depth: 0,
windowCount: 1,
});
});
});
describe("horizontal splits", () => {
it("should count single horizontal split", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 1,
columnSplits: 0,
depth: 1,
windowCount: 2,
});
});
it("should count nested horizontal splits", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 2,
columnSplits: 0,
depth: 2,
windowCount: 3,
});
});
});
describe("vertical splits", () => {
it("should count single vertical split", () => {
const layout: MosaicNode<string> = {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 0,
columnSplits: 1,
depth: 1,
windowCount: 2,
});
});
it("should count nested vertical splits", () => {
const layout: MosaicNode<string> = {
direction: "column",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 0,
columnSplits: 2,
depth: 2,
windowCount: 3,
});
});
});
describe("mixed splits", () => {
it("should count mixed horizontal and vertical splits", () => {
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 1,
columnSplits: 1,
depth: 2,
windowCount: 3,
});
});
it("should handle complex nested mixed splits", () => {
// Layout: row split with column split on left and row split on right
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: {
direction: "row",
first: "window-3",
second: "window-4",
splitPercentage: 50,
},
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 2,
columnSplits: 1,
depth: 2,
windowCount: 4,
});
});
it("should handle quad layout (2x2 grid)", () => {
// Quad layout: row split, each side has column split
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: {
direction: "column",
first: "window-3",
second: "window-4",
splitPercentage: 50,
},
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result).toEqual({
rowSplits: 1,
columnSplits: 2,
depth: 2,
windowCount: 4,
});
});
});
describe("depth calculation", () => {
it("should calculate correct depth for unbalanced tree", () => {
// Deep on one side, shallow on other
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
},
second: "window-4",
splitPercentage: 50,
};
const result = analyzeLayoutStats(layout);
expect(result.depth).toBe(3);
expect(result.windowCount).toBe(4);
});
});
});
describe("calculateSmartDirection", () => {
describe("null and empty layouts", () => {
it("should default to row for null layout", () => {
const result = calculateSmartDirection(null);
expect(result).toBe("row");
});
it("should default to row for single window", () => {
const result = calculateSmartDirection("window-1");
expect(result).toBe("row");
});
});
describe("balanced layouts", () => {
it("should return row when splits are equal", () => {
// 1 row split, 1 column split - equal, default to row
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("row");
});
it("should return row when no splits exist yet", () => {
// Just two windows with one split
const layout: MosaicNode<string> = {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
// 1 row split, 0 column splits -> row > column, should favor column
expect(result).toBe("column");
});
});
describe("unbalanced layouts", () => {
it("should return column when more horizontal splits exist", () => {
// 2 row splits, 0 column splits
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("column");
});
it("should return row when more vertical splits exist", () => {
// 0 row splits, 2 column splits
const layout: MosaicNode<string> = {
direction: "column",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("row");
});
it("should favor column when significantly more horizontal splits", () => {
// 5 row splits, 1 column split
const layout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: {
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
},
second: "w3",
splitPercentage: 50,
},
second: "w4",
splitPercentage: 50,
},
second: {
direction: "column",
first: "w5",
second: "w6",
splitPercentage: 50,
},
splitPercentage: 50,
},
second: "w7",
splitPercentage: 50,
};
const result = calculateSmartDirection(layout);
expect(result).toBe("column");
});
});
});
describe("insertWindow", () => {
describe("first window insertion", () => {
it("should return window ID for null layout", () => {
const config: LayoutConfig = {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow(null, "new-window", config);
expect(result).toBe("new-window");
});
});
describe("row mode insertion", () => {
it("should create horizontal split in row mode", () => {
const config: LayoutConfig = {
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
});
});
it("should respect custom split percentage in row mode", () => {
const config: LayoutConfig = {
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 70,
});
});
it("should place new window on left when position is first", () => {
const config: LayoutConfig = {
insertionMode: "row",
splitPercentage: 50,
insertionPosition: "first",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "row",
first: "window-2", // New window on left
second: "window-1",
splitPercentage: 50,
});
});
});
describe("column mode insertion", () => {
it("should create vertical split in column mode", () => {
const config: LayoutConfig = {
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
});
});
it("should respect custom split percentage in column mode", () => {
const config: LayoutConfig = {
insertionMode: "column",
splitPercentage: 30,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 30,
});
});
it("should place new window on top when position is first", () => {
const config: LayoutConfig = {
insertionMode: "column",
splitPercentage: 50,
insertionPosition: "first",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "column",
first: "window-2", // New window on top
second: "window-1",
splitPercentage: 50,
});
});
});
describe("smart mode insertion", () => {
it("should use smart direction for single window", () => {
const config: LayoutConfig = {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toEqual({
direction: "row", // Smart defaults to row for first split
first: "window-1",
second: "window-2",
splitPercentage: 50,
});
});
it("should balance horizontal splits by adding vertical", () => {
// Existing layout: 2 horizontal splits
const existingLayout: MosaicNode<string> = {
direction: "row",
first: {
direction: "row",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const config: LayoutConfig = {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
expect(result).toHaveProperty("direction", "column");
expect(result).toHaveProperty("second", "window-4");
});
it("should balance vertical splits by adding horizontal", () => {
// Existing layout: 2 vertical splits
const existingLayout: MosaicNode<string> = {
direction: "column",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: "window-3",
splitPercentage: 50,
};
const config: LayoutConfig = {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow(existingLayout, "window-4", config);
expect(result).toHaveProperty("direction", "row");
expect(result).toHaveProperty("second", "window-4");
});
});
describe("complex layout insertion", () => {
it("should handle insertion into complex nested layout", () => {
// Quad layout (1 row, 2 column splits)
const quadLayout: MosaicNode<string> = {
direction: "row",
first: {
direction: "column",
first: "window-1",
second: "window-2",
splitPercentage: 50,
},
second: {
direction: "column",
first: "window-3",
second: "window-4",
splitPercentage: 50,
},
splitPercentage: 50,
};
const config: LayoutConfig = {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
};
const result = insertWindow(quadLayout, "window-5", config);
// More column splits than row, should add row
expect(result).toHaveProperty("direction", "row");
expect(result).toHaveProperty("first", quadLayout);
expect(result).toHaveProperty("second", "window-5");
});
});
describe("split percentage edge cases", () => {
it("should handle minimum split percentage", () => {
const config: LayoutConfig = {
insertionMode: "row",
splitPercentage: 10,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toHaveProperty("splitPercentage", 10);
});
it("should handle maximum split percentage", () => {
const config: LayoutConfig = {
insertionMode: "row",
splitPercentage: 90,
insertionPosition: "second",
};
const result = insertWindow("window-1", "window-2", config);
expect(result).toHaveProperty("splitPercentage", 90);
});
});
});

118
src/lib/layout-utils.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { MosaicNode } from "react-mosaic-component";
import type { LayoutConfig } from "@/types/app";
/**
* Statistics about the layout tree structure
*/
export interface LayoutStats {
/** Number of horizontal splits in the tree */
rowSplits: number;
/** Number of vertical splits in the tree */
columnSplits: number;
/** Maximum depth of the tree */
depth: number;
/** Total number of windows (leaf nodes) */
windowCount: number;
}
/**
* Analyzes the layout tree and returns statistics
* Used by smart direction algorithm to balance splits
*/
export function analyzeLayoutStats(
node: MosaicNode<string> | null
): LayoutStats {
if (node === null) {
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 0 };
}
if (typeof node === "string") {
// Leaf node (window ID)
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 1 };
}
// Branch node - recursively analyze children
const firstStats = analyzeLayoutStats(node.first);
const secondStats = analyzeLayoutStats(node.second);
return {
rowSplits:
firstStats.rowSplits +
secondStats.rowSplits +
(node.direction === "row" ? 1 : 0),
columnSplits:
firstStats.columnSplits +
secondStats.columnSplits +
(node.direction === "column" ? 1 : 0),
depth: Math.max(firstStats.depth, secondStats.depth) + 1,
windowCount: firstStats.windowCount + secondStats.windowCount,
};
}
/**
* Calculates the optimal split direction to balance the layout tree
* Returns 'column' if there are more horizontal splits (to balance)
* Returns 'row' if there are more vertical splits or equal (default to horizontal)
*/
export function calculateSmartDirection(
layout: MosaicNode<string> | null
): "row" | "column" {
if (layout === null) {
return "row"; // Default to horizontal for first split
}
const stats = analyzeLayoutStats(layout);
// If more horizontal splits, add vertical to balance
if (stats.rowSplits > stats.columnSplits) {
return "column";
}
// Otherwise, default to horizontal (including when equal)
return "row";
}
/**
* Inserts a new window into the layout tree according to the layout configuration
*
* @param currentLayout - The current layout tree (null if no windows yet)
* @param newWindowId - The ID of the new window to insert
* @param config - Layout configuration specifying how to insert the window
* @returns The new layout tree with the window inserted
*/
export function insertWindow(
currentLayout: MosaicNode<string> | null,
newWindowId: string,
config: LayoutConfig
): MosaicNode<string> {
// First window - just return the window ID as leaf node
if (currentLayout === null) {
return newWindowId;
}
// Determine split direction based on insertion mode
let direction: "row" | "column";
if (config.insertionMode === "row") {
direction = "row";
} else if (config.insertionMode === "column") {
direction = "column";
} else {
// smart mode - calculate balanced direction
direction = calculateSmartDirection(currentLayout);
}
// Determine which side gets the new window
const [firstNode, secondNode] =
config.insertionPosition === "first"
? [newWindowId, currentLayout] // New window on left/top
: [currentLayout, newWindowId]; // New window on right/bottom (default)
// Create split node with new window
return {
direction,
first: firstNode,
second: secondNode,
splitPercentage: config.splitPercentage,
};
}

View File

@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { migrateState, validateState, CURRENT_VERSION } from "./migrations";
describe("migrations", () => {
describe("v6 to v7 migration", () => {
it("should convert numeric labels to number field", () => {
describe("v6 to v8 migration (v6→v7→v8)", () => {
it("should convert numeric labels to number field and add layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -26,14 +26,31 @@ describe("migrations", () => {
const migrated = migrateState(oldState);
// Should migrate to v8
expect(migrated.__version).toBe(CURRENT_VERSION);
// v6→v7: numeric labels converted to number
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBeUndefined();
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBeUndefined();
// v7→v8: layoutConfig added
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
});
it("should convert non-numeric labels to number with label", () => {
it("should convert non-numeric labels to number with label and add layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -57,13 +74,19 @@ describe("migrations", () => {
const migrated = migrateState(oldState);
expect(migrated.__version).toBe(CURRENT_VERSION);
// v6→v7: non-numeric labels preserved
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBe("Main");
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBe("Development");
// v7→v8: layoutConfig added
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
});
it("should handle mixed numeric and text labels", () => {
it("should handle mixed numeric and text labels and add layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -93,12 +116,19 @@ describe("migrations", () => {
const migrated = migrateState(oldState);
expect(migrated.__version).toBe(CURRENT_VERSION);
// v6→v7: mixed labels handled correctly
expect(migrated.workspaces.ws1.number).toBe(1);
expect(migrated.workspaces.ws1.label).toBeUndefined();
expect(migrated.workspaces.ws2.number).toBe(2);
expect(migrated.workspaces.ws2.label).toBe("Main");
expect(migrated.workspaces.ws3.number).toBe(3);
expect(migrated.workspaces.ws3.label).toBeUndefined();
// v7→v8: layoutConfig added to all workspaces
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws3.layoutConfig).toBeDefined();
});
it("should validate migrated state", () => {
@@ -121,6 +151,79 @@ describe("migrations", () => {
});
});
describe("v7 to v8 migration", () => {
it("should add layoutConfig to existing workspaces", () => {
const v7State = {
__version: 7,
windows: {
w1: { id: "w1", appId: "profile", props: {} },
w2: { id: "w2", appId: "nip", props: {} },
},
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
number: 1,
label: undefined,
layout: null,
windowIds: [],
},
ws2: {
id: "ws2",
number: 2,
label: "Development",
layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 },
windowIds: ["w1", "w2"],
},
},
};
const migrated = migrateState(v7State);
expect(migrated.__version).toBe(8);
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
// Existing fields should be preserved
expect(migrated.workspaces.ws2.label).toBe("Development");
expect(migrated.workspaces.ws2.layout).toEqual({
direction: "row",
first: "w1",
second: "w2",
splitPercentage: 50,
});
});
it("should validate v7→v8 migrated state", () => {
const v7State = {
__version: 7,
windows: { w1: { id: "w1", appId: "profile", props: {} } },
activeWorkspaceId: "ws1",
workspaces: {
ws1: {
id: "ws1",
number: 1,
layout: "w1",
windowIds: ["w1"],
},
},
};
const migrated = migrateState(v7State);
expect(validateState(migrated)).toBe(true);
});
});
describe("validateState", () => {
it("should validate correct state structure", () => {
const state = {

View File

@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 7;
export const CURRENT_VERSION = 8;
/**
* Migration function type
@@ -67,6 +67,29 @@ const migrations: Record<number, MigrationFn> = {
workspaces: migratedWorkspaces,
};
},
// Migration from v7 to v8 - adds layoutConfig to workspaces
7: (state: any) => {
const migratedWorkspaces: Record<string, any> = {};
// Add default layoutConfig to each workspace
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
migratedWorkspaces[id] = {
...workspace,
layoutConfig: {
insertionMode: "smart", // New smart default (auto-balance)
splitPercentage: 50, // Matches old 50/50 behavior
insertionPosition: "second", // Matches old right/bottom behavior
autoPreset: undefined, // No preset by default
},
};
}
return {
...state,
__version: 8,
workspaces: migratedWorkspaces,
};
},
};
/**

View File

@@ -26,12 +26,45 @@ export interface WindowInstance {
commandString?: string; // Original command that created this window (e.g., "profile alice@domain.com")
}
/**
* Configuration for how new windows are inserted into the workspace layout tree
*/
export interface LayoutConfig {
/**
* How to determine split direction for new windows
* - 'smart': Auto-balance horizontal/vertical splits (recommended)
* - 'row': Always horizontal splits (side-by-side)
* - 'column': Always vertical splits (stacked)
*/
insertionMode: "smart" | "row" | "column";
/**
* Split percentage for new windows (10-90)
* Example: 70 means existing content gets 70%, new window gets 30%
*/
splitPercentage: number;
/**
* Where to place the new window
* - 'first': Left (for row) or Top (for column)
* - 'second': Right (for row) or Bottom (for column)
*/
insertionPosition: "first" | "second";
/**
* Optional: Auto-maintain a preset layout structure
* When set, system tries to preserve this preset when adding windows
*/
autoPreset?: string;
}
export interface Workspace {
id: string;
number: number; // Numeric identifier for shortcuts (e.g., Cmd+1, Cmd+2)
label?: string; // Optional user-editable label
layout: MosaicNode<string> | null;
windowIds: string[];
layoutConfig: LayoutConfig; // How new windows are inserted into layout
}
export interface RelayInfo {