refactor(layouts): simplify to global config + preserve windows + add UI dropdown

Simplified layout system based on user feedback:

**1. Global Layout Config (Simpler)**
- Moved layoutConfig from per-workspace to global state
- One configuration applies to all workspaces (easier to understand)
- Updated state migration v8→v9 to move config to global level
- Updated WorkspaceSettings UI to edit global config
- Renamed updateWorkspaceLayoutConfig → updateLayoutConfig

**2. Preserve Extra Windows (Fixed Bug)**
- Fixed applyPresetToLayout to keep windows beyond preset slots
- When applying 4-window grid to 6 windows, windows 5-6 are preserved
- Extra windows stacked vertically on right side (70/30 split)
- No more window loss when applying presets

**3. Layout Dropdown in TabBar (Better UX)**
- Added dropdown menu next to workspace tabs
- Shows all available presets with icons (Grid2X2, Columns2, Split)
- Displays window requirements and availability
- Disables presets that need more windows than available
- One-click preset application with toast feedback
- More accessible than /layout command

All tests passing (457 passed). State migration handles v6→v7→v8→v9 correctly.

Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alejandro Gómez
2025-12-18 12:24:00 +01:00
parent 52f39a8073
commit 4db7e9690c
9 changed files with 277 additions and 115 deletions

View File

@@ -1,24 +1,71 @@
import { Plus, SlidersHorizontal } from "lucide-react";
import { Plus, SlidersHorizontal, Grid2X2, Columns2, Split } from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { WorkspaceSettings } from "./WorkspaceSettings";
import { getAllPresets } from "@/lib/layout-presets";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { toast } from "sonner";
import { useState } from "react";
export function TabBar() {
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
const { state, setActiveWorkspace, createWorkspace, applyPresetLayout } = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(
null,
);
const [settingsOpen, setSettingsOpen] = useState(false);
const activeWorkspace = workspaces[activeWorkspaceId];
const windowCount = activeWorkspace?.windowIds.length || 0;
const presets = getAllPresets();
const handleNewTab = () => {
createWorkspace();
};
const handleSettingsClick = (e: React.MouseEvent, workspaceId: string) => {
const handleSettingsClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent workspace switch
setSettingsWorkspaceId(workspaceId);
setSettingsOpen(true);
};
const handleApplyPreset = (presetId: string) => {
const preset = presets.find((p) => p.id === presetId);
if (!preset) return;
if (windowCount < preset.slots) {
toast.error(`Not enough windows`, {
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
});
return;
}
try {
applyPresetLayout(preset);
toast.success(`Layout applied`, {
description: `Applied "${preset.name}" preset to workspace ${activeWorkspace.number}`,
});
} catch (error) {
toast.error(`Failed to apply layout`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
};
const getPresetIcon = (presetId: string) => {
switch (presetId) {
case "side-by-side":
return <Columns2 className="h-4 w-4" />;
case "main-sidebar":
return <Split className="h-4 w-4" />;
case "grid":
return <Grid2X2 className="h-4 w-4" />;
default:
return <Grid2X2 className="h-4 w-4" />;
}
};
// Sort workspaces by number
@@ -46,19 +93,60 @@ export function TabBar() {
: ws.number}
</button>
<button
onClick={(e) => handleSettingsClick(e, ws.id)}
onClick={(e) => handleSettingsClick(e)}
className={cn(
"absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
ws.id === activeWorkspaceId
? "text-primary-foreground hover:bg-primary-foreground/20"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
aria-label={`Settings for workspace ${ws.number}`}
aria-label="Layout settings"
>
<SlidersHorizontal className="h-3 w-3" />
</button>
</div>
))}
{/* Layout Preset Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
aria-label="Apply layout preset"
>
<Grid2X2 className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Apply Layout Preset
</div>
{presets.map((preset) => {
const canApply = windowCount >= preset.slots;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => handleApplyPreset(preset.id)}
disabled={!canApply}
className="flex items-center gap-3 cursor-pointer"
>
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{preset.name}</div>
<div className="text-xs text-muted-foreground truncate">
{canApply
? `${preset.slots} windows`
: `Needs ${preset.slots} (have ${windowCount})`}
</div>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
@@ -71,15 +159,10 @@ export function TabBar() {
</div>
</div>
{settingsWorkspaceId && (
<WorkspaceSettings
workspaceId={settingsWorkspaceId}
open={settingsWorkspaceId !== null}
onOpenChange={(open) => {
if (!open) setSettingsWorkspaceId(null);
}}
/>
)}
<WorkspaceSettings
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
</>
);
}

View File

@@ -18,34 +18,29 @@ import type { LayoutConfig } from "@/types/app";
import { cn } from "@/lib/utils";
interface WorkspaceSettingsProps {
workspaceId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WorkspaceSettings({
workspaceId,
open,
onOpenChange,
}: WorkspaceSettingsProps) {
const { state, updateWorkspaceLayoutConfig } = useGrimoire();
const workspace = state.workspaces[workspaceId];
const { state, updateLayoutConfig } = useGrimoire();
// Local state for settings
const [insertionMode, setInsertionMode] = useState<LayoutConfig["insertionMode"]>(
workspace?.layoutConfig?.insertionMode || "smart"
state.layoutConfig?.insertionMode || "smart"
);
const [splitPercentage, setSplitPercentage] = useState(
workspace?.layoutConfig?.splitPercentage || 50
state.layoutConfig?.splitPercentage || 50
);
const [insertionPosition, setInsertionPosition] = useState<LayoutConfig["insertionPosition"]>(
workspace?.layoutConfig?.insertionPosition || "second"
state.layoutConfig?.insertionPosition || "second"
);
if (!workspace) return null;
const handleSave = () => {
updateWorkspaceLayoutConfig(workspaceId, {
updateLayoutConfig({
insertionMode,
splitPercentage,
insertionPosition,
@@ -63,12 +58,9 @@ export function WorkspaceSettings({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
Workspace {workspace.number}
{workspace.label && ` - ${workspace.label}`} Settings
</DialogTitle>
<DialogTitle>Layout Settings</DialogTitle>
<DialogDescription>
Configure how new windows are inserted into this workspace layout.
Configure how new windows are inserted into all workspaces.
</DialogDescription>
</DialogHeader>

View File

@@ -131,16 +131,16 @@ describe("findLowestAvailableWorkspaceNumber", () => {
describe("addWindow", () => {
// Helper to create minimal test state
const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode<string> | null = null): GrimoireState => ({
__version: 8,
__version: 9,
windows: {},
activeWorkspaceId: "test-workspace",
layoutConfig, // Global layout config (not per-workspace)
workspaces: {
"test-workspace": {
id: "test-workspace",
number: 1,
windowIds: [],
layout: existingLayout,
layoutConfig,
},
},
});

View File

@@ -76,8 +76,8 @@ export const addWindow = (
commandString: payload.commandString,
};
// Insert window using workspace layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
// Insert window using global layout configuration
const newLayout = insertWindow(ws.layout, newWindowId, state.layoutConfig);
return {
...state,
@@ -347,30 +347,18 @@ export const updateWindow = (
};
/**
* Updates the layout configuration for a workspace.
* Controls how new windows are inserted into the workspace layout.
* Updates the global layout configuration.
* Controls how new windows are inserted into all workspaces.
*/
export const updateWorkspaceLayoutConfig = (
export const updateLayoutConfig = (
state: GrimoireState,
workspaceId: string,
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
layoutConfig: Partial<GrimoireState["layoutConfig"]>,
): GrimoireState => {
const workspace = state.workspaces[workspaceId];
if (!workspace) {
return state; // Workspace doesn't exist, return unchanged
}
return {
...state,
workspaces: {
...state.workspaces,
[workspaceId]: {
...workspace,
layoutConfig: {
...workspace.layoutConfig,
...layoutConfig,
},
},
layoutConfig: {
...state.layoutConfig,
...layoutConfig,
},
};
};

View File

@@ -18,14 +18,14 @@ 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
},
},
},
layoutConfig: {
insertionMode: "smart", // Smart auto-balancing by default
splitPercentage: 50, // Equal split
insertionPosition: "second", // New windows on right/bottom
autoPreset: undefined, // No preset maintenance
},
};
// Custom storage with error handling and migrations
@@ -229,14 +229,9 @@ export const useGrimoire = () => {
[setState],
);
const updateWorkspaceLayoutConfig = useCallback(
(
workspaceId: string,
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
) =>
setState((prev) =>
Logic.updateWorkspaceLayoutConfig(prev, workspaceId, layoutConfig),
),
const updateLayoutConfig = useCallback(
(layoutConfig: Partial<GrimoireState["layoutConfig"]>) =>
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)),
[setState],
);
@@ -259,7 +254,7 @@ export const useGrimoire = () => {
setActiveWorkspace,
setActiveAccount,
setActiveAccountRelays,
updateWorkspaceLayoutConfig,
updateLayoutConfig,
applyPresetLayout,
};
};

View File

@@ -129,6 +129,7 @@ export function collectWindowIds(
/**
* Applies a preset layout to existing windows
* Takes the first N windows from the current layout and arranges them according to the preset
* Preserves any remaining windows by adding them to the right side of the preset
*/
export function applyPresetToLayout(
currentLayout: MosaicNode<string> | null,
@@ -144,11 +145,36 @@ export function applyPresetToLayout(
);
}
// Take first N windows for the preset
const windowsToUse = windowIds.slice(0, preset.slots);
// Split windows: first N for preset, rest to preserve
const presetWindows = windowIds.slice(0, preset.slots);
const remainingWindows = windowIds.slice(preset.slots);
// Fill template with window IDs
return fillLayoutTemplate(preset.template, windowsToUse);
// Fill template with preset windows
let result = fillLayoutTemplate(preset.template, presetWindows);
// If there are remaining windows, add them to the right side
if (remainingWindows.length > 0) {
// Create a vertical stack for remaining windows
let remainingStack: MosaicNode<string> = remainingWindows[0];
for (let i = 1; i < remainingWindows.length; i++) {
remainingStack = {
direction: "column",
first: remainingStack,
second: remainingWindows[i],
splitPercentage: 50,
};
}
// Put preset on left, remaining on right (70/30 split)
result = {
direction: "row",
first: result,
second: remainingStack,
splitPercentage: 70, // Give more space to the preset layout
};
}
return result;
}
/**

View File

@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
import { migrateState, validateState, CURRENT_VERSION } from "./migrations";
describe("migrations", () => {
describe("v6 to v8 migration (v6→v7→v8)", () => {
it("should convert numeric labels to number field and add layoutConfig", () => {
describe("v6 to v9 migration (v6→v7→v8→v9)", () => {
it("should convert numeric labels to number field and add global layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -26,7 +26,7 @@ describe("migrations", () => {
const migrated = migrateState(oldState);
// Should migrate to v8
// Should migrate to v9
expect(migrated.__version).toBe(CURRENT_VERSION);
// v6→v7: numeric labels converted to number
@@ -35,22 +35,19 @@ describe("migrations", () => {
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({
// v7→v8→v9: layoutConfig moved to global state
expect(migrated.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
// Workspaces should NOT have layoutConfig
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
});
it("should convert non-numeric labels to number with label and add layoutConfig", () => {
it("should convert non-numeric labels to number with label and add global layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -81,12 +78,13 @@ describe("migrations", () => {
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();
// v7→v8→v9: layoutConfig is global, not per-workspace
expect(migrated.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
});
it("should handle mixed numeric and text labels and add layoutConfig", () => {
it("should handle mixed numeric and text labels and add global layoutConfig", () => {
const oldState = {
__version: 6,
windows: {},
@@ -125,10 +123,11 @@ describe("migrations", () => {
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();
// v7→v8→v9: layoutConfig is global
expect(migrated.layoutConfig).toBeDefined();
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws3.layoutConfig).toBeUndefined();
});
it("should validate migrated state", () => {
@@ -151,10 +150,10 @@ describe("migrations", () => {
});
});
describe("v7 to v8 migration", () => {
it("should add layoutConfig to existing workspaces", () => {
const v7State = {
__version: 7,
describe("v8 to v9 migration", () => {
it("should move layoutConfig from workspaces to global state", () => {
const v8State = {
__version: 8,
windows: {
w1: { id: "w1", appId: "profile", props: {} },
w2: { id: "w2", appId: "nip", props: {} },
@@ -167,6 +166,12 @@ describe("migrations", () => {
label: undefined,
layout: null,
windowIds: [],
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
},
},
ws2: {
id: "ws2",
@@ -174,27 +179,33 @@ describe("migrations", () => {
label: "Development",
layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 },
windowIds: ["w1", "w2"],
layoutConfig: {
insertionMode: "row",
splitPercentage: 70,
insertionPosition: "first",
autoPreset: undefined,
},
},
},
};
const migrated = migrateState(v7State);
const migrated = migrateState(v8State);
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({
expect(migrated.__version).toBe(9);
// layoutConfig should be at global level (from first workspace)
expect(migrated.layoutConfig).toEqual({
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
});
// Existing fields should be preserved
// Workspaces should NOT have layoutConfig
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
// Other fields should be preserved
expect(migrated.workspaces.ws2.label).toBe("Development");
expect(migrated.workspaces.ws2.layout).toEqual({
direction: "row",
@@ -204,9 +215,9 @@ describe("migrations", () => {
});
});
it("should validate v7→v8 migrated state", () => {
const v7State = {
__version: 7,
it("should validate v8→v9 migrated state", () => {
const v8State = {
__version: 8,
windows: { w1: { id: "w1", appId: "profile", props: {} } },
activeWorkspaceId: "ws1",
workspaces: {
@@ -215,11 +226,17 @@ describe("migrations", () => {
number: 1,
layout: "w1",
windowIds: ["w1"],
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
},
},
},
};
const migrated = migrateState(v7State);
const migrated = migrateState(v8State);
expect(validateState(migrated)).toBe(true);
});
});
@@ -230,6 +247,12 @@ describe("migrations", () => {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
},
workspaces: {
default: {
id: "default",
@@ -247,6 +270,11 @@ describe("migrations", () => {
const state = {
windows: {},
activeWorkspaceId: "default",
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
workspaces: {
default: {
id: "default",
@@ -265,6 +293,29 @@ describe("migrations", () => {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
};
expect(validateState(state)).toBe(false);
});
it("should reject state without layoutConfig", () => {
const state = {
__version: CURRENT_VERSION,
windows: {},
activeWorkspaceId: "default",
workspaces: {
default: {
id: "default",
number: 1,
layout: null,
windowIds: [],
},
},
};
expect(validateState(state)).toBe(false);

View File

@@ -8,7 +8,7 @@
import { GrimoireState } from "@/types/app";
import { toast } from "sonner";
export const CURRENT_VERSION = 8;
export const CURRENT_VERSION = 9;
/**
* Migration function type
@@ -90,6 +90,32 @@ const migrations: Record<number, MigrationFn> = {
workspaces: migratedWorkspaces,
};
},
// Migration from v8 to v9 - moves layoutConfig from per-workspace to global
8: (state: any) => {
const migratedWorkspaces: Record<string, any> = {};
// Get layoutConfig from first workspace (or use default)
const firstWorkspace = Object.values(state.workspaces || {})[0] as any;
const layoutConfig = firstWorkspace?.layoutConfig || {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
autoPreset: undefined,
};
// Remove layoutConfig from all workspaces
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
const { layoutConfig: _, ...workspaceWithoutConfig } = workspace as any;
migratedWorkspaces[id] = workspaceWithoutConfig;
}
return {
...state,
__version: 9,
workspaces: migratedWorkspaces,
layoutConfig, // Move to global state
};
},
};
/**
@@ -108,6 +134,7 @@ export function validateState(state: any): state is GrimoireState {
!state.windows ||
!state.workspaces ||
!state.activeWorkspaceId ||
!state.layoutConfig ||
typeof state.__version !== "number"
) {
return false;

View File

@@ -65,7 +65,6 @@ export interface Workspace {
label?: string; // Optional user-editable label
layout: MosaicNode<string> | null;
windowIds: string[];
layoutConfig: LayoutConfig; // How new windows are inserted into layout
}
export interface RelayInfo {
@@ -85,6 +84,7 @@ export interface GrimoireState {
windows: Record<string, WindowInstance>;
workspaces: Record<string, Workspace>;
activeWorkspaceId: string;
layoutConfig: LayoutConfig; // Global configuration for window insertion (applies to all workspaces)
activeAccount?: {
pubkey: string;
relays?: UserRelays;