Scrim now uses pointer-events-none — it dims visually but clicks pass through to the cards beneath. Dismissal relies on onMouseLeave on the wrapper div, which fires when the mouse leaves the card area. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
import { useState } from 'react'
|
|
import { CircleDot, CheckCircle2, XCircle, Circle, RotateCcw } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import type { BranchResponse } from '@/types/branching'
|
|
|
|
type BranchStatus = BranchResponse['status']
|
|
|
|
interface StatusConfig {
|
|
icon: React.ElementType
|
|
textClass: string
|
|
badgeClass: string
|
|
borderClass: string
|
|
label: string
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<BranchStatus, StatusConfig> = {
|
|
active: {
|
|
icon: CircleDot,
|
|
textClass: 'text-accent',
|
|
badgeClass: 'bg-accent-dim text-accent-text',
|
|
borderClass: 'border-accent/50',
|
|
label: 'Active',
|
|
},
|
|
solved: {
|
|
icon: CheckCircle2,
|
|
textClass: 'text-success',
|
|
badgeClass: 'bg-success-dim text-success',
|
|
borderClass: 'border-success/30',
|
|
label: 'Solved',
|
|
},
|
|
dead_end: {
|
|
icon: XCircle,
|
|
textClass: 'text-danger',
|
|
badgeClass: 'bg-danger-dim text-danger',
|
|
borderClass: 'border-danger/30',
|
|
label: 'Dead End',
|
|
},
|
|
untried: {
|
|
icon: Circle,
|
|
textClass: 'text-muted-foreground',
|
|
badgeClass: 'bg-elevated text-muted-foreground',
|
|
borderClass: 'border-default',
|
|
label: 'Untried',
|
|
},
|
|
revived: {
|
|
icon: RotateCcw,
|
|
textClass: 'text-warning',
|
|
badgeClass: 'bg-warning-dim text-warning',
|
|
borderClass: 'border-warning/30',
|
|
label: 'Revived',
|
|
},
|
|
}
|
|
|
|
interface BranchNodeProps {
|
|
branch: BranchResponse
|
|
depth: number
|
|
isActive: boolean
|
|
onClick: (branchId: string) => void
|
|
}
|
|
|
|
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
|
const [isHovered, setIsHovered] = useState(false)
|
|
const config = STATUS_CONFIG[branch.status]
|
|
const Icon = config.icon
|
|
|
|
const hasDetail = branch.context_summary || branch.status_reason
|
|
const showExpanded = isHovered && hasDetail && !isActive
|
|
|
|
return (
|
|
<div
|
|
style={{ marginLeft: `${depth * 12}px` }}
|
|
className="relative"
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{/* Base card — always visible */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onClick(branch.id)}
|
|
className={cn(
|
|
'w-full text-left rounded-lg border p-2.5 transition-all duration-150',
|
|
isActive
|
|
? cn('bg-card', config.borderClass)
|
|
: 'bg-card/60 border-default hover:bg-card hover:border-hover',
|
|
)}
|
|
>
|
|
<CardHeader icon={Icon} config={config} branch={branch} isActive={isActive} />
|
|
|
|
{/* Inline detail for active branch */}
|
|
{isActive && hasDetail && (
|
|
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
|
|
<BranchDetail branch={branch} />
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Expanded card — floats directly on top of the base card */}
|
|
{showExpanded && (
|
|
<>
|
|
{/* Visual dim — pointer-events-none so clicks pass through to cards */}
|
|
<div className="fixed inset-0 z-40 bg-black/30 pointer-events-none" />
|
|
|
|
{/* Expanded card positioned over the original */}
|
|
<div
|
|
className={cn(
|
|
'absolute z-50 inset-x-0 top-0',
|
|
'bg-card border rounded-lg p-2.5',
|
|
'shadow-[0_8px_32px_rgba(0,0,0,0.5)]',
|
|
config.borderClass,
|
|
)}
|
|
style={{
|
|
/* Grow slightly wider than the base card */
|
|
marginLeft: -8,
|
|
marginRight: -8,
|
|
width: 'calc(100% + 16px)',
|
|
}}
|
|
>
|
|
<CardHeader icon={Icon} config={config} branch={branch} isActive={false} />
|
|
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
|
|
<BranchDetail branch={branch} />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Card header row — shared between base and expanded states */
|
|
function CardHeader({
|
|
icon: Icon,
|
|
config,
|
|
branch,
|
|
isActive,
|
|
}: {
|
|
icon: React.ElementType
|
|
config: StatusConfig
|
|
branch: BranchResponse
|
|
isActive: boolean
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={14} className={cn('shrink-0', config.textClass)} />
|
|
<span
|
|
className={cn(
|
|
'flex-1 text-sm truncate',
|
|
isActive ? 'text-heading font-medium' : 'text-foreground'
|
|
)}
|
|
>
|
|
{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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Detail content — shared between inline (active) and expanded (hover) */
|
|
function BranchDetail({ branch }: { branch: BranchResponse }) {
|
|
return (
|
|
<>
|
|
{branch.context_summary && (
|
|
<>
|
|
{branch.context_summary.tried.length > 0 && (
|
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
|
<span className="text-foreground font-medium">Tried:</span>{' '}
|
|
{branch.context_summary.tried.join(', ')}
|
|
</p>
|
|
)}
|
|
{branch.context_summary.concluded && (
|
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
|
<span className="text-foreground font-medium">Result:</span>{' '}
|
|
{branch.context_summary.concluded}
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
{branch.status_reason && (
|
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
|
<span className="text-foreground font-medium">Reason:</span>{' '}
|
|
{branch.status_reason}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pt-0.5">
|
|
<span>{branch.step_count} step{branch.step_count !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|