feat: branch hover detail as floating popover instead of inline expand
- Active branch: detail shows inline below the card header (no push) - Hovered non-active: detail appears as a floating popover to the RIGHT of the card (position: absolute, z-50, left-full) - Popover has shadow, status-colored border, fade+zoom entrance - Cards no longer shift or push siblings on hover - Extracted BranchDetail component shared by both inline and popover Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } 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'
|
||||||
@@ -18,7 +18,7 @@ const STATUS_CONFIG: Record<BranchStatus, StatusConfig> = {
|
|||||||
icon: CircleDot,
|
icon: CircleDot,
|
||||||
textClass: 'text-accent',
|
textClass: 'text-accent',
|
||||||
badgeClass: 'bg-accent-dim text-accent-text',
|
badgeClass: 'bg-accent-dim text-accent-text',
|
||||||
borderClass: 'border-accent/40',
|
borderClass: 'border-accent/50',
|
||||||
label: 'Active',
|
label: 'Active',
|
||||||
},
|
},
|
||||||
solved: {
|
solved: {
|
||||||
@@ -60,34 +60,35 @@ interface BranchNodeProps {
|
|||||||
|
|
||||||
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
const config = STATUS_CONFIG[branch.status]
|
const config = STATUS_CONFIG[branch.status]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
const showDetail = isActive || isHovered
|
|
||||||
|
const hasDetail = branch.context_summary || branch.status_reason
|
||||||
|
const showPopover = isHovered && hasDetail && !isActive
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
style={{ marginLeft: `${depth * 12}px` }}
|
style={{ marginLeft: `${depth * 12}px` }}
|
||||||
|
className="relative"
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
|
{/* Card */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onClick(branch.id)}
|
onClick={() => onClick(branch.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left rounded-lg border p-2.5 transition-all duration-200 ease-out',
|
'w-full text-left rounded-lg border p-2.5 transition-all duration-150',
|
||||||
'origin-center',
|
|
||||||
isActive
|
isActive
|
||||||
? cn('bg-card border-accent/50 shadow-[0_0_0_1px_rgba(249,115,22,0.15)]', config.borderClass)
|
? cn('bg-card', config.borderClass)
|
||||||
: 'bg-card/60 border-default hover:bg-card hover:border-hover',
|
: 'bg-card/60 border-default hover:bg-card hover:border-hover',
|
||||||
showDetail && !isActive && 'scale-[1.02] shadow-[0_2px_8px_rgba(0,0,0,0.25)]',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon
|
<Icon size={14} className={cn('shrink-0', config.textClass)} />
|
||||||
size={14}
|
|
||||||
className={cn('shrink-0', config.textClass)}
|
|
||||||
/>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 text-sm truncate',
|
'flex-1 text-sm truncate',
|
||||||
@@ -106,46 +107,65 @@ export function BranchNode({ branch, depth, isActive, onClick }: BranchNodeProps
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail section — animates from scale-y-0 to scale-y-100 */}
|
{/* Inline detail for ACTIVE branch only */}
|
||||||
|
{isActive && hasDetail && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-default/50 space-y-1">
|
||||||
|
<BranchDetail branch={branch} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Floating popover for HOVERED non-active branches */}
|
||||||
|
{showPopover && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-all duration-200 ease-out origin-top',
|
'absolute z-50 left-full top-0 ml-2 w-64',
|
||||||
showDetail
|
'bg-card border rounded-lg p-3 space-y-1',
|
||||||
? 'max-h-[120px] opacity-100 scale-y-100 mt-2 pt-2 border-t border-default/50'
|
'shadow-[0_4px_20px_rgba(0,0,0,0.4)]',
|
||||||
: 'max-h-0 opacity-0 scale-y-0 mt-0 pt-0'
|
'animate-in fade-in-0 zoom-in-95 duration-150',
|
||||||
|
config.borderClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
{branch.context_summary ? (
|
<Icon size={12} className={config.textClass} />
|
||||||
<>
|
<span className="text-xs font-medium text-heading truncate">{branch.label}</span>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] text-muted-foreground italic">No activity yet</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<BranchDetail branch={branch} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared detail content used by both inline (active) and popover (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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user