feat: add SessionTimeline and ActionMenu reusable components

SessionTimeline extracts timeline/checklist rendering from SessionDetailPage
into a reusable component for both authenticated and public session views.
ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 16:08:53 -05:00
parent c24e84d8a0
commit d1b849a5f8
2 changed files with 307 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import { useState, useRef, useEffect } from 'react'
import { MoreVertical } from 'lucide-react'
import { cn } from '@/lib/utils'
interface MenuAction {
label: string
icon?: React.ComponentType<{ className?: string }>
onClick: () => void
disabled?: boolean
variant?: 'default' | 'destructive'
}
interface ActionMenuProps {
actions: MenuAction[]
align?: 'left' | 'right' // default 'right'
}
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const handleItemClick = (action: MenuAction) => {
if (action.disabled) return
action.onClick()
setIsOpen(false)
}
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'rounded-md border border-white/10 p-2 text-white/60',
'hover:bg-white/10 hover:text-white'
)}
aria-label="Actions"
>
<MoreVertical className="h-4 w-4" />
</button>
{isOpen && (
<div
className={cn(
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
align === 'right' ? 'right-0' : 'left-0'
)}
>
{actions.map((action, index) => {
const Icon = action.icon
return (
<button
key={index}
onClick={() => handleItemClick(action)}
disabled={action.disabled}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
action.disabled
? 'cursor-not-allowed opacity-40'
: action.variant === 'destructive'
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)}
>
{Icon && <Icon className="h-4 w-4" />}
{action.label}
</button>
)
})}
</div>
)}
</div>
)
}
export type { MenuAction, ActionMenuProps }
export default ActionMenu

View File

@@ -0,0 +1,207 @@
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
import type { DecisionRecord } from '@/types'
import { cn } from '@/lib/utils'
interface SessionTimelineProps {
decisions: DecisionRecord[]
treeType?: string // 'procedural' or 'troubleshooting' (default)
startedAt: string
completedAt: string | null
showCopyButtons?: boolean // default true
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleString()
}
function formatDuration(durationSeconds: number | null | undefined) {
if (durationSeconds == null || durationSeconds < 0) return null
if (durationSeconds < 60) return `${durationSeconds}s`
const hours = Math.floor(durationSeconds / 3600)
const minutes = Math.floor((durationSeconds % 3600) / 60)
const seconds = durationSeconds % 60
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
}
export function SessionTimeline({
decisions,
treeType,
startedAt,
completedAt,
showCopyButtons = true,
}: SessionTimelineProps) {
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
const handleCopyStep = async (decision: DecisionRecord, index: number) => {
const lines: string[] = []
if (decision.question) lines.push(`Question: ${decision.question}`)
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
try {
await navigator.clipboard.writeText(lines.join('\n'))
setCopiedStepIndex(index)
setTimeout(() => setCopiedStepIndex(null), 2000)
} catch {
// Clipboard access denied
}
}
if (treeType === 'procedural') {
return (
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
<div className="space-y-2">
{decisions.map((decision, index) => {
const isCompleted = decision.answer === 'completed'
return (
<div
key={index}
className={cn(
'glass-card rounded-xl p-4',
isCompleted && 'border-l-2 border-emerald-400/50'
)}
>
<div className="flex items-start gap-3">
<span className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
)}>
{isCompleted ? '\u2713' : index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-white">{decision.question || 'Step'}</p>
{decision.notes && (
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<p className="mt-1 text-sm text-white/40">
Verification: {decision.command_output}
</p>
)}
{decision.duration_seconds != null && (
<p className="mt-1 text-xs text-white/30">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
</div>
{showCopyButtons && (
<button
onClick={() => handleCopyStep(decision, index)}
title="Copy step to clipboard"
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
>
{copiedStepIndex === index ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
)
})}
{completedAt && (
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
<span className="h-3 w-3 rounded-full bg-emerald-500" />
<span className="text-emerald-400">
Procedure completed: {formatDate(completedAt)}
</span>
</div>
)}
</div>
</div>
)
}
// Default: troubleshooting decision timeline
return (
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-white" />
<span className="text-white/40">
Session started: {formatDate(startedAt)}
</span>
</div>
{decisions.map((decision, index) => (
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
<div className="relative">
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
<div className="glass-card rounded-xl p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{decision.question && (
<p className="font-medium text-white">{decision.question}</p>
)}
{decision.answer && (
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
)}
{decision.action_performed && (
<p className="mt-1 text-sm text-white/40">
Action: {decision.action_performed}
</p>
)}
{decision.notes && (
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
{decision.command_output}
</pre>
</div>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)}
</p>
</div>
{showCopyButtons && (
<button
onClick={() => handleCopyStep(decision, index)}
title="Copy step to clipboard"
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
>
{copiedStepIndex === index ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
</div>
</div>
))}
{completedAt && (
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-emerald-400">
Session completed: {formatDate(completedAt)}
</span>
</div>
)}
</div>
</div>
)
}
export default SessionTimeline