feat: add three-panel layout

This commit is contained in:
“BeeRad”
2026-03-20 21:22:09 +11:00
parent 21290bb48a
commit 85a16e05db
9 changed files with 614 additions and 772 deletions
+3
View File
@@ -988,6 +988,9 @@ export default function FocusPanel({
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100%; min-height: 100%;
width: 100%;
max-width: 980px;
margin: 0 auto;
} }
.empty-state { .empty-state {
+3 -3
View File
@@ -16,7 +16,7 @@ import {
Sun, Sun,
Moon, Moon,
} from 'lucide-react'; } from 'lucide-react';
import type { PaneType } from '../panes/types'; import type { PaneType, NavigablePaneType } from '../panes/types';
import type { Theme } from '@/hooks/useTheme'; import type { Theme } from '@/hooks/useTheme';
interface LeftToolbarProps { interface LeftToolbarProps {
@@ -24,7 +24,7 @@ interface LeftToolbarProps {
onAddStuffClick: () => void; onAddStuffClick: () => void;
onRefreshClick: () => void; onRefreshClick: () => void;
onSettingsClick: () => void; onSettingsClick: () => void;
onPaneTypeClick: (paneType: PaneType) => void; onPaneTypeClick: (paneType: NavigablePaneType) => void;
isExpanded: boolean; isExpanded: boolean;
onToggleExpanded: () => void; onToggleExpanded: () => void;
openTabTypes: Set<PaneType>; openTabTypes: Set<PaneType>;
@@ -36,7 +36,7 @@ interface LeftToolbarProps {
const NAV_WIDTH_COLLAPSED = 50; const NAV_WIDTH_COLLAPSED = 50;
const NAV_WIDTH_EXPANDED = 280; const NAV_WIDTH_EXPANDED = 280;
const VIEW_ITEMS: Array<{ paneType: PaneType; label: string; icon: typeof LayoutList }> = [ const VIEW_ITEMS: Array<{ paneType: NavigablePaneType; label: string; icon: typeof LayoutList }> = [
{ paneType: 'views', label: 'Nodes', icon: LayoutList }, { paneType: 'views', label: 'Nodes', icon: LayoutList },
{ paneType: 'skills', label: 'Skills', icon: BookOpen }, { paneType: 'skills', label: 'Skills', icon: BookOpen },
{ paneType: 'map', label: 'Map', icon: Map }, { paneType: 'map', label: 'Map', icon: Map },
+11 -84
View File
@@ -1,75 +1,29 @@
"use client"; "use client";
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { GripVertical } from 'lucide-react';
interface SplitHandleProps { interface SplitHandleProps {
isSecondPaneOpen: boolean; onResize: (clientX: number) => void;
onOpenSecondPane: () => void; title?: string;
onResize: (newWidthPercent: number) => void;
onCloseSecondPane: () => void;
containerRef: React.RefObject<HTMLDivElement>;
toolbarWidth?: number;
} }
export default function SplitHandle({ export default function SplitHandle({
isSecondPaneOpen,
onOpenSecondPane,
onResize, onResize,
onCloseSecondPane, title = 'Drag to resize panes',
containerRef,
toolbarWidth = 50,
}: SplitHandleProps) { }: SplitHandleProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(50);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
if (!isSecondPaneOpen) {
// First drag opens the second pane at 50%
onOpenSecondPane();
}
setIsDragging(true); setIsDragging(true);
startXRef.current = e.clientX; }, []);
// Calculate current width from container
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth - toolbarWidth;
// Assume current position is at the split point
startWidthRef.current = 50; // Default to 50% for new split
}
}, [isSecondPaneOpen, onOpenSecondPane, containerRef, toolbarWidth]);
useEffect(() => { useEffect(() => {
if (!isDragging) return; if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return; onResize(e.clientX);
const containerRect = containerRef.current.getBoundingClientRect();
const containerWidth = containerRect.width - toolbarWidth;
const mouseX = e.clientX - containerRect.left - toolbarWidth;
// Calculate what percentage of the available space should be Slot B
// mouseX is distance from left edge, so slotA width = mouseX
// slotB width = containerWidth - mouseX
const slotBWidthPercent = ((containerWidth - mouseX) / containerWidth) * 100;
// Clamp between 20% and 70%
const clampedWidth = Math.max(20, Math.min(70, slotBWidthPercent));
// If dragged to less than 15%, close the pane
if (slotBWidthPercent < 15) {
onCloseSecondPane();
setIsDragging(false);
return;
}
onResize(clampedWidth);
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
@@ -87,48 +41,21 @@ export default function SplitHandle({
document.body.style.cursor = ''; document.body.style.cursor = '';
document.body.style.userSelect = ''; document.body.style.userSelect = '';
}; };
}, [isDragging, containerRef, toolbarWidth, onResize, onCloseSecondPane]); }, [isDragging, onResize]);
// When second pane is closed, show a wider handle with grip icon
if (!isSecondPaneOpen) {
return (
<div
onMouseDown={handleMouseDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: '12px',
cursor: 'col-resize',
background: isHovered ? 'var(--rah-bg-active)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s ease',
flexShrink: 0,
}}
title="Drag to split view (⌘\)"
>
<GripVertical
size={12}
color={isHovered ? '#888' : '#444'}
style={{ transition: 'color 0.15s ease' }}
/>
</div>
);
}
// When second pane is open, show resize handle
return ( return (
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
title={title}
style={{ style={{
width: '8px', width: '4px',
cursor: 'col-resize', cursor: 'col-resize',
background: isDragging ? '#22c55e' : (isHovered ? 'var(--rah-bg-active)' : 'transparent'), background: isDragging ? 'var(--rah-accent-green)' : (isHovered ? 'var(--rah-bg-active)' : 'transparent'),
transition: isDragging ? 'none' : 'background 0.15s ease', transition: isDragging ? 'none' : 'background 0.15s ease',
flexShrink: 0, flexShrink: 0,
borderRadius: '999px',
}} }}
/> />
); );
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ interface FolderViewOverlayProps {
refreshToken: number; refreshToken: number;
onDataChanged?: () => void; onDataChanged?: () => void;
onDimensionSelect?: (dimensionName: string | null) => void; onDimensionSelect?: (dimensionName: string | null) => void;
replaceWithViewsOnDimensionSelect?: boolean;
toolbarHost?: HTMLDivElement | null; toolbarHost?: HTMLDivElement | null;
} }
@@ -60,6 +61,7 @@ export default function FolderViewOverlay({
refreshToken, refreshToken,
onDataChanged, onDataChanged,
onDimensionSelect, onDimensionSelect,
replaceWithViewsOnDimensionSelect = false,
toolbarHost, toolbarHost,
}: FolderViewOverlayProps) { }: FolderViewOverlayProps) {
const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions'); const [view, setView] = useState<'dimensions' | 'nodes'>('dimensions');
@@ -171,6 +173,11 @@ export default function FolderViewOverlay({
}; };
const handleSelectDimension = (dimension: DimensionSummary) => { const handleSelectDimension = (dimension: DimensionSummary) => {
if (replaceWithViewsOnDimensionSelect) {
onDimensionSelect?.(dimension.dimension);
return;
}
setSelectedDimension(dimension); setSelectedDimension(dimension);
setView('nodes'); setView('nodes');
setNodes([]); setNodes([]);
+1
View File
@@ -56,6 +56,7 @@ export default function DimensionsPane({
refreshToken={refreshToken} refreshToken={refreshToken}
onDataChanged={onDataChanged} onDataChanged={onDataChanged}
onDimensionSelect={onDimensionSelect} onDimensionSelect={onDimensionSelect}
replaceWithViewsOnDimensionSelect={true}
toolbarHost={toolbarHost} toolbarHost={toolbarHost}
/> />
</div> </div>
+2 -1
View File
@@ -41,9 +41,10 @@ export default function PaneHeader({
e.preventDefault(); e.preventDefault();
setIsDragOver(false); setIsDragOver(false);
if (!slot) return;
const sourceSlot = e.dataTransfer.getData('application/x-rah-pane'); const sourceSlot = e.dataTransfer.getData('application/x-rah-pane');
if (sourceSlot && sourceSlot !== slot && onSwapPanes) { if (sourceSlot && sourceSlot !== slot && onSwapPanes) {
onSwapPanes(); onSwapPanes(sourceSlot as typeof slot, slot);
} }
}; };
+1 -1
View File
@@ -137,7 +137,7 @@ export default function SkillsPane({
{loading ? ( {loading ? (
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div> <div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
) : selectedSkill ? ( ) : selectedSkill ? (
<div> <div style={{ width: '100%', maxWidth: '980px', margin: '0 auto' }}>
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<div style={{ color: '#eee', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div> <div style={{ color: '#eee', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
<div style={{ color: '#888', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div> <div style={{ color: '#888', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
+9 -6
View File
@@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { Node } from '@/types/database'; import { Node } from '@/types/database';
export type SlotId = 'A' | 'B' | 'C';
export type NavigablePaneType = Exclude<PaneType, 'node'>;
// Stub type for delegation (delegation system removed in rah-light) // Stub type for delegation (delegation system removed in rah-light)
export type AgentDelegation = { export type AgentDelegation = {
id: number; id: number;
@@ -30,18 +33,18 @@ export interface SlotState {
// Actions panes can emit to the layout // Actions panes can emit to the layout
export type PaneAction = export type PaneAction =
| { type: 'open-node'; nodeId: number; targetSlot?: 'A' | 'B' } | { type: 'open-node'; nodeId: number; targetSlot?: SlotId }
| { type: 'open-dimension'; dimension: string; targetSlot?: 'A' | 'B' } | { type: 'open-dimension'; dimension: string; targetSlot?: SlotId }
| { type: 'switch-pane-type'; paneType: PaneType } | { type: 'switch-pane-type'; paneType: PaneType }
| { type: 'close-pane' }; | { type: 'close-pane' };
// Common props for all panes // Common props for all panes
export interface BasePaneProps { export interface BasePaneProps {
slot: 'A' | 'B'; slot: SlotId;
isActive: boolean; isActive: boolean;
onPaneAction?: (action: PaneAction) => void; onPaneAction?: (action: PaneAction) => void;
onCollapse?: () => void; onCollapse?: () => void;
onSwapPanes?: () => void; onSwapPanes?: (source: SlotId, target: SlotId) => void;
tabBar?: React.ReactNode; tabBar?: React.ReactNode;
} }
@@ -99,9 +102,9 @@ export interface TablePaneProps extends BasePaneProps {
// Pane header props // Pane header props
export interface PaneHeaderProps { export interface PaneHeaderProps {
slot?: 'A' | 'B'; slot?: SlotId;
onCollapse?: () => void; onCollapse?: () => void;
onSwapPanes?: () => void; onSwapPanes?: (source: SlotId, target: SlotId) => void;
tabBar?: React.ReactNode; tabBar?: React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
toolbarHostRef?: (node: HTMLDivElement | null) => void; toolbarHostRef?: (node: HTMLDivElement | null) => void;