feat: resizable sidebar, branch hover preview, Fork Point label fix
- Sidebar: click-and-drag resize handle (200-500px range), accent highlight on drag, cursor changes to col-resize - BranchNode: hover/active expands to show context_summary (tried, result), status_reason, and step count with smooth animation - ForkCard: "Fork Point" label changed from text-muted to text-accent-text for visibility against bg-card Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
|
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { BranchResponse } from '@/types/branching'
|
import type { BranchResponse } from '@/types/branching'
|
||||||
@@ -52,40 +53,86 @@ interface BranchNodeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const config = STATUS_CONFIG[branch.status]
|
const config = STATUS_CONFIG[branch.status]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
const showDetail = isActive || isHovered
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onClick={() => onClick(branch.id)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
||||||
className={cn(
|
|
||||||
'w-full flex items-center gap-2 py-2 pr-3 text-left rounded-md transition-colors',
|
|
||||||
'hover:bg-elevated',
|
|
||||||
isActive && 'bg-accent-dim border-l-2 border-accent'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<button
|
||||||
size={14}
|
type="button"
|
||||||
className={cn('shrink-0', config.textClass)}
|
onClick={() => onClick(branch.id)}
|
||||||
/>
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 text-sm truncate',
|
'w-full flex items-center gap-2 py-2 pr-3 text-left rounded-md transition-all duration-150',
|
||||||
isActive ? 'text-heading font-medium' : 'text-primary'
|
'hover:bg-elevated',
|
||||||
|
isActive && 'bg-accent-dim border-l-2 border-accent'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{branch.label}
|
<Icon
|
||||||
</span>
|
size={14}
|
||||||
<span
|
className={cn('shrink-0', config.textClass)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-sm truncate',
|
||||||
|
isActive ? 'text-heading font-medium' : 'text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{branch.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
|
||||||
|
config.badgeClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable detail panel */}
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0',
|
'overflow-hidden transition-all duration-200 ease-out',
|
||||||
config.badgeClass
|
showDetail ? 'max-h-[120px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
|
style={{ paddingLeft: `${24 + depth * 16}px` }}
|
||||||
>
|
>
|
||||||
{config.label}
|
<div className="py-1.5 pr-3 space-y-1">
|
||||||
</span>
|
{branch.context_summary ? (
|
||||||
</button>
|
<>
|
||||||
|
{branch.context_summary.tried.length > 0 && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
<span className="text-muted">Tried:</span>{' '}
|
||||||
|
{branch.context_summary.tried.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{branch.context_summary.concluded && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
<span className="text-muted">Result:</span>{' '}
|
||||||
|
{branch.context_summary.concluded}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-muted italic">No activity yet</p>
|
||||||
|
)}
|
||||||
|
{branch.status_reason && (
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
|
<span className="text-muted">Reason:</span>{' '}
|
||||||
|
{branch.status_reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-[10px] text-muted">
|
||||||
|
<span>{branch.step_count} step{branch.step_count !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function ForkCard({ fork, selectedBranchId, onSelectOption }: ForkCardPro
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GitFork size={16} className="text-accent shrink-0" />
|
<GitFork size={16} className="text-accent shrink-0" />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-accent-text">
|
||||||
Fork Point
|
Fork Point
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Renders all branching components with mock data — no API calls.
|
* Renders all branching components with mock data — no API calls.
|
||||||
* Use this to validate visual design before wiring into FlowPilotSessionPage.
|
* Use this to validate visual design before wiring into FlowPilotSessionPage.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { BranchMap } from '@/components/session/BranchMap'
|
import { BranchMap } from '@/components/session/BranchMap'
|
||||||
import { ForkCard } from '@/components/session/ForkCard'
|
import { ForkCard } from '@/components/session/ForkCard'
|
||||||
import { BranchTransitionBar } from '@/components/session/BranchTransitionBar'
|
import { BranchTransitionBar } from '@/components/session/BranchTransitionBar'
|
||||||
@@ -421,6 +421,8 @@ export default function DevBranchingPage() {
|
|||||||
const [showHandoff, setShowHandoff] = useState(false)
|
const [showHandoff, setShowHandoff] = useState(false)
|
||||||
const [previousBranchId, setPreviousBranchId] = useState<string | null>(null)
|
const [previousBranchId, setPreviousBranchId] = useState<string | null>(null)
|
||||||
const [showTransition, setShowTransition] = useState(false)
|
const [showTransition, setShowTransition] = useState(false)
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(280)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
|
||||||
const activeBranch = MOCK_BRANCHES.find(b => b.id === activeBranchId)!
|
const activeBranch = MOCK_BRANCHES.find(b => b.id === activeBranchId)!
|
||||||
const previousBranch = previousBranchId ? MOCK_BRANCHES.find(b => b.id === previousBranchId) ?? null : null
|
const previousBranch = previousBranchId ? MOCK_BRANCHES.find(b => b.id === previousBranchId) ?? null : null
|
||||||
@@ -436,15 +438,61 @@ export default function DevBranchingPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Resizable sidebar ──
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isResizingRef = useRef(false)
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isResizingRef.current = true
|
||||||
|
setIsResizing(true)
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleMouseMove(e: MouseEvent) {
|
||||||
|
if (!isResizingRef.current) return
|
||||||
|
const newWidth = Math.min(Math.max(e.clientX, 200), 500)
|
||||||
|
setSidebarWidth(newWidth)
|
||||||
|
}
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!isResizingRef.current) return
|
||||||
|
isResizingRef.current = false
|
||||||
|
setIsResizing(false)
|
||||||
|
document.body.style.cursor = ''
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Branch Map Sidebar */}
|
{/* Branch Map Sidebar */}
|
||||||
<aside className="w-[260px] shrink-0 border-r border-default bg-sidebar p-3 overflow-y-auto">
|
<aside
|
||||||
|
ref={sidebarRef}
|
||||||
|
style={{ width: sidebarWidth }}
|
||||||
|
className="shrink-0 border-r border-default bg-sidebar p-3 overflow-y-auto relative"
|
||||||
|
>
|
||||||
<BranchMap
|
<BranchMap
|
||||||
branches={MOCK_BRANCHES}
|
branches={MOCK_BRANCHES}
|
||||||
activeBranchId={activeBranchId}
|
activeBranchId={activeBranchId}
|
||||||
onSelectBranch={handleSwitchBranch}
|
onSelectBranch={handleSwitchBranch}
|
||||||
/>
|
/>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 right-0 w-1 h-full cursor-col-resize transition-colors',
|
||||||
|
'hover:bg-accent/40',
|
||||||
|
isResizing ? 'bg-accent/60' : 'bg-transparent'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user