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:
100
frontend/src/components/common/ActionMenu.tsx
Normal file
100
frontend/src/components/common/ActionMenu.tsx
Normal 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
|
||||||
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
207
frontend/src/components/session/SessionTimeline.tsx
Normal 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
|
||||||
Reference in New Issue
Block a user