feat(layouts): Phase 2 - workspace settings UI

Add per-workspace layout configuration UI with visual controls:

**Core Changes:**
- Add updateWorkspaceLayoutConfig() function to logic.ts for updating workspace layout settings
- Expose updateWorkspaceLayoutConfig in useGrimoire hook

**UI Components:**
- Create WorkspaceSettings dialog with three sections:
  * Insertion Mode selector (Balanced/Horizontal/Vertical) with icons
  * Split Percentage slider (10-90%) with real-time preview
  * Insertion Position toggle (Left-Top/Right-Bottom)
- Add settings icon (SlidersHorizontal) to workspace tabs that appears on hover
- Settings button opens configuration dialog for that workspace

**UX Details:**
- Settings icon only visible on hover to reduce visual clutter
- Clear visual feedback for selected options with primary color highlights
- Preview section shows current configuration in plain language
- Reset to Defaults button restores smart mode defaults
- Prevents workspace switch when clicking settings icon

**Icons Used:**
- Sparkles: Balanced (smart auto-balancing)
- SplitSquareHorizontal: Horizontal splits
- SplitSquareVertical: Vertical splits
- SlidersHorizontal: Settings access

Each workspace can now have independent layout behavior configured through an intuitive UI.
This commit is contained in:
Alejandro Gómez
2025-12-18 12:05:47 +01:00
parent cc6f8d646b
commit a78b5226ce
4 changed files with 335 additions and 28 deletions

View File

@@ -1,50 +1,85 @@
import { Plus } from "lucide-react";
import { Plus, SlidersHorizontal } from "lucide-react";
import { Button } from "./ui/button";
import { useGrimoire } from "@/core/state";
import { cn } from "@/lib/utils";
import { WorkspaceSettings } from "./WorkspaceSettings";
import { useState } from "react";
export function TabBar() {
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
const { workspaces, activeWorkspaceId } = state;
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(
null,
);
const handleNewTab = () => {
createWorkspace();
};
const handleSettingsClick = (e: React.MouseEvent, workspaceId: string) => {
e.stopPropagation(); // Prevent workspace switch
setSettingsWorkspaceId(workspaceId);
};
// Sort workspaces by number
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number,
);
return (
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
<div className="flex items-center gap-1 flex-nowrap">
{sortedWorkspaces.map((ws) => (
<button
key={ws.id}
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 text-xs font-mono rounded transition-colors whitespace-nowrap flex-shrink-0",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
<>
<div className="h-8 border-t border-border bg-background flex items-center px-2 gap-1 overflow-x-auto">
<div className="flex items-center gap-1 flex-nowrap">
{sortedWorkspaces.map((ws) => (
<div key={ws.id} className="relative group flex-shrink-0">
<button
onClick={() => setActiveWorkspace(ws.id)}
className={cn(
"px-3 py-1 pr-7 text-xs font-mono rounded transition-colors whitespace-nowrap",
ws.id === activeWorkspaceId
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{ws.label && ws.label.trim()
? `${ws.number} ${ws.label}`
: ws.number}
</button>
<button
onClick={(e) => handleSettingsClick(e, ws.id)}
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}`}
>
<SlidersHorizontal className="h-3 w-3" />
</button>
</div>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
onClick={handleNewTab}
aria-label="Create new workspace"
>
{ws.label && ws.label.trim()
? `${ws.number} ${ws.label}`
: ws.number}
</button>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 ml-1 flex-shrink-0"
onClick={handleNewTab}
aria-label="Create new workspace"
>
<Plus className="h-3 w-3" />
</Button>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
</div>
{settingsWorkspaceId && (
<WorkspaceSettings
workspaceId={settingsWorkspaceId}
open={settingsWorkspaceId !== null}
onOpenChange={(open) => {
if (!open) setSettingsWorkspaceId(null);
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,231 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Label } from "./ui/Label";
import { Button } from "./ui/button";
import {
Sparkles,
SplitSquareHorizontal,
SplitSquareVertical,
} from "lucide-react";
import { useGrimoire } from "@/core/state";
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];
// Local state for settings
const [insertionMode, setInsertionMode] = useState<LayoutConfig["insertionMode"]>(
workspace?.layoutConfig?.insertionMode || "smart"
);
const [splitPercentage, setSplitPercentage] = useState(
workspace?.layoutConfig?.splitPercentage || 50
);
const [insertionPosition, setInsertionPosition] = useState<LayoutConfig["insertionPosition"]>(
workspace?.layoutConfig?.insertionPosition || "second"
);
if (!workspace) return null;
const handleSave = () => {
updateWorkspaceLayoutConfig(workspaceId, {
insertionMode,
splitPercentage,
insertionPosition,
});
onOpenChange(false);
};
const handleReset = () => {
setInsertionMode("smart");
setSplitPercentage(50);
setInsertionPosition("second");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
Workspace {workspace.number}
{workspace.label && ` - ${workspace.label}`} Settings
</DialogTitle>
<DialogDescription>
Configure how new windows are inserted into this workspace layout.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Insertion Mode */}
<div className="space-y-3">
<Label className="text-sm font-medium">Insertion Mode</Label>
<div className="grid gap-2">
<button
onClick={() => setInsertionMode("smart")}
className={cn(
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
insertionMode === "smart"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground/50"
)}
>
<Sparkles className="h-5 w-5 text-primary" />
<div className="flex-1 text-left">
<div className="font-medium text-sm">Balanced (auto)</div>
<div className="text-xs text-muted-foreground">
Automatically balances horizontal and vertical splits
</div>
</div>
</button>
<button
onClick={() => setInsertionMode("row")}
className={cn(
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
insertionMode === "row"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground/50"
)}
>
<SplitSquareHorizontal className="h-5 w-5 text-primary" />
<div className="flex-1 text-left">
<div className="font-medium text-sm">Horizontal (side-by-side)</div>
<div className="text-xs text-muted-foreground">
New windows always split horizontally
</div>
</div>
</button>
<button
onClick={() => setInsertionMode("column")}
className={cn(
"flex items-center gap-3 p-3 rounded-md border-2 transition-all",
insertionMode === "column"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground/50"
)}
>
<SplitSquareVertical className="h-5 w-5 text-primary" />
<div className="flex-1 text-left">
<div className="font-medium text-sm">Vertical (stacked)</div>
<div className="text-xs text-muted-foreground">
New windows always split vertically
</div>
</div>
</button>
</div>
</div>
{/* Split Percentage */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Split Percentage</Label>
<span className="text-sm text-muted-foreground">
{splitPercentage}% / {100 - splitPercentage}%
</span>
</div>
<input
type="range"
min="10"
max="90"
value={splitPercentage}
onChange={(e) => setSplitPercentage(Number(e.target.value))}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Existing content gets {splitPercentage}%</span>
<span>New window gets {100 - splitPercentage}%</span>
</div>
</div>
{/* Insertion Position */}
<div className="space-y-3">
<Label className="text-sm font-medium">Insertion Position</Label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setInsertionPosition("first")}
className={cn(
"p-3 rounded-md border-2 transition-all text-sm",
insertionPosition === "first"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground/50"
)}
>
<div className="font-medium">Left / Top</div>
<div className="text-xs text-muted-foreground mt-1">
New window first
</div>
</button>
<button
onClick={() => setInsertionPosition("second")}
className={cn(
"p-3 rounded-md border-2 transition-all text-sm",
insertionPosition === "second"
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground/50"
)}
>
<div className="font-medium">Right / Bottom</div>
<div className="text-xs text-muted-foreground mt-1">
New window second
</div>
</button>
</div>
</div>
{/* Preview */}
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="text-xs text-muted-foreground mb-2">Preview:</div>
<div className="text-sm">
<span className="font-medium">
{insertionMode === "smart" && "Smart mode"}
{insertionMode === "row" && "Horizontal splits"}
{insertionMode === "column" && "Vertical splits"}
</span>
{" · "}
<span>
{splitPercentage}%/{100 - splitPercentage}% split
</span>
{" · "}
<span>
New window on{" "}
{insertionPosition === "first" ? "left/top" : "right/bottom"}
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between">
<Button variant="outline" onClick={handleReset}>
Reset to Defaults
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -344,3 +344,32 @@ export const updateWindow = (
},
};
};
/**
* Updates the layout configuration for a workspace.
* Controls how new windows are inserted into the workspace layout.
*/
export const updateWorkspaceLayoutConfig = (
state: GrimoireState,
workspaceId: string,
layoutConfig: Partial<GrimoireState["workspaces"][string]["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,
},
},
},
};
};

View File

@@ -229,6 +229,17 @@ export const useGrimoire = () => {
[setState],
);
const updateWorkspaceLayoutConfig = useCallback(
(
workspaceId: string,
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
) =>
setState((prev) =>
Logic.updateWorkspaceLayoutConfig(prev, workspaceId, layoutConfig),
),
[setState],
);
return {
state,
locale: state.locale || browserLocale,
@@ -242,5 +253,6 @@ export const useGrimoire = () => {
setActiveWorkspace,
setActiveAccount,
setActiveAccountRelays,
updateWorkspaceLayoutConfig,
};
};