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
},
},
},
};