feat: add three-panel layout
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user