diff --git a/frontend/src/components/common/ActionMenu.tsx b/frontend/src/components/common/ActionMenu.tsx new file mode 100644 index 00000000..f7f5a20d --- /dev/null +++ b/frontend/src/components/common/ActionMenu.tsx @@ -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(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 ( +
+ + + {isOpen && ( +
+ {actions.map((action, index) => { + const Icon = action.icon + return ( + + ) + })} +
+ )} +
+ ) +} + +export type { MenuAction, ActionMenuProps } + +export default ActionMenu diff --git a/frontend/src/components/session/SessionTimeline.tsx b/frontend/src/components/session/SessionTimeline.tsx new file mode 100644 index 00000000..90811010 --- /dev/null +++ b/frontend/src/components/session/SessionTimeline.tsx @@ -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(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 ( +
+

Procedure Steps

+
+ {decisions.map((decision, index) => { + const isCompleted = decision.answer === 'completed' + return ( +
+
+ + {isCompleted ? '\u2713' : index + 1} + +
+

{decision.question || 'Step'}

+ {decision.notes && ( +

+ Notes: {decision.notes} +

+ )} + {decision.command_output && ( +

+ Verification: {decision.command_output} +

+ )} + {decision.duration_seconds != null && ( +

+ Duration: {formatDuration(decision.duration_seconds)} +

+ )} +
+ {showCopyButtons && ( + + )} +
+
+ ) + })} + {completedAt && ( +
+ + + Procedure completed: {formatDate(completedAt)} + +
+ )} +
+
+ ) + } + + // Default: troubleshooting decision timeline + return ( +
+

Decision Timeline

+
+
+ + + Session started: {formatDate(startedAt)} + +
+ + {decisions.map((decision, index) => ( +
+
+ +
+
+
+ {decision.question && ( +

{decision.question}

+ )} + {decision.answer && ( +

Answer: {decision.answer}

+ )} + {decision.action_performed && ( +

+ Action: {decision.action_performed} +

+ )} + {decision.notes && ( +

+ Notes: {decision.notes} +

+ )} + {decision.command_output && ( +
+

Command Output

+
+                          {decision.command_output}
+                        
+
+ )} + {decision.duration_seconds != null && ( +

+ Duration: {formatDuration(decision.duration_seconds)} +

+ )} +

+ {formatDate(decision.timestamp)} +

+
+ {showCopyButtons && ( + + )} +
+
+
+
+ ))} + + {completedAt && ( +
+ + + Session completed: {formatDate(completedAt)} + +
+ )} +
+
+ ) +} + +export default SessionTimeline