import { useEffect, useRef, useState } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' import { useCustomStepFlow } from '@/hooks/useCustomStepFlow' import { useSessionTimer } from '@/hooks/useSessionTimer' import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types' import { cn, safeGetItem, safeSetItem } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session' import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react' import { toast } from '@/lib/toast' import { Modal } from '@/components/common/Modal' import { ShareSessionModal } from '@/components/session/ShareSessionModal' import { CSATModal, hasBeenRated } from '@/components/session/CSATModal' import { StepFeedback } from '@/components/session/StepFeedback' import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare' interface LocationState { sessionId?: string prefillClientName?: string prefillTicketNumber?: string } type CompletionSource = 'standard' | 'custom' export function TreeNavigationPage() { const { id: treeId } = useParams<{ id: string }>() const navigate = useNavigate() const location = useLocation() const locationState = location.state as LocationState | undefined const [tree, setTree] = useState(null) const [session, setSession] = useState(null) const [currentNodeId, setCurrentNodeId] = useState('root') const [pathTaken, setPathTaken] = useState(['root']) const [decisions, setDecisions] = useState([]) const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState(new Date().toISOString()) const [notes, setNotes] = useState('') const [commandOutput, setCommandOutput] = useState('') const [commandOutputOpen, setCommandOutputOpen] = useState(false) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isCompleting, setIsCompleting] = useState(false) const [showOutcomeModal, setShowOutcomeModal] = useState(false) const [pendingCompletionDecision, setPendingCompletionDecision] = useState(null) const [completionSource, setCompletionSource] = useState('standard') const [copiedCommand, setCopiedCommand] = useState(null) const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false) const [selectingOption, setSelectingOption] = useState(null) const [copiedForTicket, setCopiedForTicket] = useState(false) const [isCopyingForTicket, setIsCopyingForTicket] = useState(false) const [showSharePopover, setShowSharePopover] = useState(false) const [showShareModal, setShowShareModal] = useState(false) const [showCsatModal, setShowCsatModal] = useState(false) const [copiedShareLink, setCopiedShareLink] = useState(false) const [isCopyingShareLink, setIsCopyingShareLink] = useState(false) const sharePopoverRef = useRef(null) const handleCopyCommand = (text: string) => { navigator.clipboard.writeText(text) setCopiedCommand(text) setTimeout(() => setCopiedCommand(null), 2000) } const handleCopyForTicket = async () => { if (!session || isCopyingForTicket) return setIsCopyingForTicket(true) try { const content = await sessionsApi.export(session.id, { format: 'psa', include_timestamps: true, include_tree_info: true, }) if (content) { await navigator.clipboard.writeText(content) setCopiedForTicket(true) setTimeout(() => setCopiedForTicket(false), 2000) toast.success('Copied progress notes to clipboard') } } catch (err) { console.error('Copy for ticket failed:', err) toast.error('Failed to copy notes') } finally { setIsCopyingForTicket(false) } } const handleCopyShareLink = async () => { if (!session || isCopyingShareLink) return setIsCopyingShareLink(true) try { const allShares = await sessionsApi.listMyShares() const existingShare = getLatestActiveShareForSession(allShares, session.id) let shareUrl: string if (existingShare) { shareUrl = buildSessionShareUrl(existingShare) } else { const newShare = await sessionsApi.createShare(session.id, { visibility: 'account' }) shareUrl = buildSessionShareUrl(newShare) } await navigator.clipboard.writeText(shareUrl) setCopiedShareLink(true) toast.success('Share link copied to clipboard') setTimeout(() => setCopiedShareLink(false), 2000) } catch (err) { console.error('Copy share link failed:', err) toast.error('Failed to copy share link') } finally { setIsCopyingShareLink(false) } } // Close share popover on outside click useEffect(() => { if (!showSharePopover) return const handleMouseDown = (e: MouseEvent) => { if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) { setShowSharePopover(false) } } document.addEventListener('mousedown', handleMouseDown) return () => document.removeEventListener('mousedown', handleMouseDown) }, [showSharePopover]) // Close share popover on Escape key useEffect(() => { if (!showSharePopover) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setShowSharePopover(false) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [showSharePopover]) // Session metadata (prefill from Repeat Last Session) const [ticketNumber, setTicketNumber] = useState(locationState?.prefillTicketNumber || '') const [clientName, setClientName] = useState(locationState?.prefillClientName || '') const [showMetadataForm, setShowMetadataForm] = useState(true) // Session timer const timerDisplay = useSessionTimer(session?.started_at) // Scratchpad state const [scratchpadOpen, setScratchpadOpen] = useState(() => { return safeGetItem('scratchpad-collapsed') === 'false' }) const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => { if (!structure) return null if (structure.id === nodeId) return structure if (structure.children) { for (const child of structure.children) { const found = findNode(nodeId, child) if (found) return found } } return null } const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => { const enteredAtMs = new Date(enteredAtIso).getTime() const exitedAtMs = new Date(exitedAtIso).getTime() if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0 return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000)) } const deriveCurrentStepEnteredAt = (sessionData: Session): string => { if (!sessionData.decisions || sessionData.decisions.length === 0) { return sessionData.started_at } const lastDecision = sessionData.decisions[sessionData.decisions.length - 1] return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at } const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => { const exitedAt = new Date().toISOString() const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt setPendingCompletionDecision({ ...completionDecision, timestamp: exitedAt, entered_at: enteredAt, exited_at: exitedAt, duration_seconds: calculateDurationSeconds(enteredAt, exitedAt), }) setCompletionSource(source) setShowOutcomeModal(true) } const closeOutcomeModal = () => { if (isCompleting) return setShowOutcomeModal(false) setPendingCompletionDecision(null) setCompletionSource('standard') } const handleCsatClose = () => { setShowCsatModal(false) if (session) { navigate(`/sessions/${session.id}`) } } // Custom step flow (creation, post-step actions, continuation, branching, forking) const customStepFlow = useCustomStepFlow({ tree, session, currentNodeId, pathTaken, decisions, notes, findNode, setCurrentNodeId, setPathTaken, setDecisions, setNotes, setError, onEnterNode: setCurrentStepEnteredAt, isCompleting, onRequestCompletion: (completionDecision) => { openCompletionModal(completionDecision, 'custom') }, }) // Inject command_output into the last decision (for custom steps) before continuing const updateLastDecisionWithCommandOutput = async () => { const output = commandOutput.trim() || null if (!output || !session || decisions.length === 0) return const updatedDecisions = [...decisions] updatedDecisions[updatedDecisions.length - 1] = { ...updatedDecisions[updatedDecisions.length - 1], command_output: output, } setDecisions(updatedDecisions) try { await sessionsApi.update(session.id, { decisions: updatedDecisions }) } catch (err) { console.error('Failed to update decision with command output:', err) } } const handleCustomContinueToDescendant = async () => { await updateLastDecisionWithCommandOutput() setCommandOutput('') setCommandOutputOpen(false) customStepFlow.handleContinueToDescendant() } const handleCustomBranchCompleteWithOutput = async () => { await updateLastDecisionWithCommandOutput() customStepFlow.handleCustomBranchComplete() } const handleScratchpadSave = async (content: string) => { if (!session) return await sessionsApi.updateScratchpad(session.id, content) } useEffect(() => { if (treeId) { loadTreeAndSession() } }, [treeId]) const loadTreeAndSession = async () => { setIsLoading(true) setError(null) try { const treeData = await treesApi.get(treeId!) // Safety redirect: procedural trees should use the procedural navigator if (treeData.tree_type === 'procedural') { navigate(`/flows/${treeId}/navigate`, { replace: true, state: locationState, }) return } setTree(treeData) // If resuming a session if (locationState?.sessionId) { const sessionData = await sessionsApi.get(locationState.sessionId) setSession(sessionData) setPathTaken(sessionData.path_taken) setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root') setDecisions(sessionData.decisions as DecisionRecord[]) setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData)) customStepFlow.initCustomSteps(sessionData.custom_steps || []) setTicketNumber(sessionData.ticket_number || '') setClientName(sessionData.client_name || '') setShowMetadataForm(false) } } catch (err) { setError('Failed to load tree') console.error(err) } finally { setIsLoading(false) } } const startSession = async () => { if (!tree) return setIsLoading(true) try { const newSession = await sessionsApi.create({ tree_id: tree.id, ticket_number: ticketNumber || undefined, client_name: clientName || undefined, }) setSession(newSession) setCurrentStepEnteredAt(newSession.started_at) setShowMetadataForm(false) // Save for "Repeat Last Session" safeSetItem('last-session', JSON.stringify({ tree_id: tree.id, tree_name: tree.name, client_name: clientName || '', ticket_number: ticketNumber || '', })) } catch (err) { setError('Failed to start session') console.error(err) } finally { setIsLoading(false) } } const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => { if (!session || !tree || selectingOption) return setSelectingOption(_optionId) const node = findNode(currentNodeId, tree.tree_structure) if (!node) { setSelectingOption(null); return } const exitedAt = new Date().toISOString() const enteredAt = currentStepEnteredAt || session.started_at || exitedAt const newDecision: DecisionRecord = { node_id: currentNodeId, question: node.question || null, answer: optionLabel, action_performed: null, notes: notes || null, automation_used: false, timestamp: exitedAt, entered_at: enteredAt, exited_at: exitedAt, duration_seconds: calculateDurationSeconds(enteredAt, exitedAt), attachments: [], } const newPath = [...pathTaken, nextNodeId] const newDecisions = [...decisions, newDecision] setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(nextNodeId) setCurrentStepEnteredAt(exitedAt) setNotes('') setCommandOutput('') setCommandOutputOpen(false) try { await sessionsApi.update(session.id, { path_taken: newPath, decisions: newDecisions, }) } catch (err) { console.error('Failed to update session:', err) } finally { setSelectingOption(null) } } const handleContinue = async (actionPerformed?: string) => { if (!session || !tree) return const node = findNode(currentNodeId, tree.tree_structure) if (!node || !node.next_node_id) return const exitedAt = new Date().toISOString() const enteredAt = currentStepEnteredAt || session.started_at || exitedAt const newDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, action_performed: actionPerformed || node.title || 'Action completed', notes: notes || null, command_output: commandOutput.trim() || null, automation_used: false, timestamp: exitedAt, entered_at: enteredAt, exited_at: exitedAt, duration_seconds: calculateDurationSeconds(enteredAt, exitedAt), attachments: [], } const newPath = [...pathTaken, node.next_node_id] const newDecisions = [...decisions, newDecision] setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(node.next_node_id) setCurrentStepEnteredAt(exitedAt) setNotes('') setCommandOutput('') setCommandOutputOpen(false) try { await sessionsApi.update(session.id, { path_taken: newPath, decisions: newDecisions, }) } catch (err) { console.error('Failed to update session:', err) } } const handleComplete = async () => { if (!session || !tree) return const node = findNode(currentNodeId, tree.tree_structure) if (!node) return const completionDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, action_performed: node.title || 'Session completed', notes: notes || null, command_output: commandOutput.trim() || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [], } openCompletionModal(completionDecision, 'standard') } const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => { if (!session) return setIsCompleting(true) setError(null) try { let finalDecisions = decisions if (pendingCompletionDecision) { finalDecisions = [...decisions, pendingCompletionDecision] setDecisions(finalDecisions) await sessionsApi.update(session.id, { decisions: finalDecisions, }) } await sessionsApi.complete(session.id, data) setShowOutcomeModal(false) setPendingCompletionDecision(null) if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) { customStepFlow.setShowForkModal(true) } else if (!hasBeenRated(session.id)) { setShowCsatModal(true) } else { navigate(`/sessions/${session.id}`) } } catch (err) { console.error('Failed to complete session:', err) setError('Failed to complete session. Check console for details.') } finally { setIsCompleting(false) } } const handleGoBack = () => { if (pathTaken.length <= 1) return const newPath = pathTaken.slice(0, -1) const removedDecision = decisions[decisions.length - 1] const newDecisions = decisions.slice(0, -1) setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(newPath[newPath.length - 1]) setCurrentStepEnteredAt(new Date().toISOString()) // Preload fields from the removed decision when revisiting const prevOutput = removedDecision?.command_output || '' setCommandOutput(prevOutput) setCommandOutputOpen(!!prevOutput) } const handleBreadcrumbJump = (nodeId: string, index: number) => { setPathTaken(prev => prev.slice(0, index + 1)) setDecisions(prev => prev.slice(0, index)) setCurrentNodeId(nodeId) setCurrentStepEnteredAt(new Date().toISOString()) setNotes('') setCommandOutput('') setCommandOutputOpen(false) } // Compute current node for keyboard shortcuts (must be before any returns for hooks rules) const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null const currentCustomStep = customStepFlow.findCustomStep(currentNodeId) const currentOptions = currentNode?.options || [] // Keyboard shortcuts - must be called unconditionally (React hooks rules) useTreeNavigationShortcuts({ onSelectOption: (index) => { const option = currentOptions[index] if (option && session && tree && !selectingOption) { handleSelectOption(option.id, option.label, option.next_node_id) } }, onGoBack: handleGoBack, onShowShortcuts: () => setShortcutsModalOpen(true), onContinue: () => { if (currentNode?.type === 'action' && currentNode.next_node_id) { handleContinue() } else if (currentNode?.type === 'solution') { handleComplete() } }, optionCount: currentOptions.length, canGoBack: pathTaken.length > 1 && !showMetadataForm && !isLoading && !selectingOption, canContinue: !showMetadataForm && !isLoading && !selectingOption && (currentNode?.type === 'action' || currentNode?.type === 'solution'), }) if (isLoading) { return (
) } if (error || !tree) { return (
{error || 'Tree not found'}
) } // Session metadata form if (showMetadataForm) { return (

{tree.name}

{tree.description}

Session Details

Optional: Add ticket and client info for easier tracking

setTicketNumber(e.target.value)} placeholder="e.g., INC0012345" className={cn( 'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20' )} />
setClientName(e.target.value)} placeholder="e.g., Acme Corp" className={cn( 'mt-1 block w-full rounded-md border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20' )} />
) } if (!currentNode && !currentCustomStep) { return (
Invalid tree structure
) } return (
{/* Main Content */}
{/* Header */}

{tree.name}

{timerDisplay && ( {timerDisplay} )}
{(ticketNumber || clientName) && (

{ticketNumber && `Ticket: ${ticketNumber}`} {ticketNumber && clientName && ' · '} {clientName && `Client: ${clientName}`}

)}
{/* Share Progress Popover */}
{showSharePopover && (
{/* Copy Progress Summary */} {/* Copy Share Link */} {/* Divider */}
{/* Manage Share Links */}
)}
{/* Breadcrumb */}
{pathTaken.map((nodeId, index) => { const node = findNode(nodeId, tree?.tree_structure) const customStep = customStepFlow.findCustomStep(nodeId) const label = node?.question || node?.title || customStep?.step_data.title || nodeId const truncatedLabel = label.length > 30 ? `${label.slice(0, 30)}...` : label return ( {index > 0 && } {index < pathTaken.length - 1 ? ( ) : ( {truncatedLabel} )} ) })}
{/* Current Node */}
{/* Answer placeholder guard */} {currentNode && currentNode.type === 'answer' && (

This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.

)} {/* Decision Node */} {currentNode && currentNode.type === 'decision' && ( <>

{currentNode.question}

{currentNode.help_text && (
)}
{currentNode.options?.map((option, index) => ( ))}
{/* Previously-created custom steps at this node */} {customStepFlow.customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && (

Your Custom Steps

{customStepFlow.customSteps .filter(cs => cs.inserted_after_node_id === currentNodeId) .map(cs => ( ))}
)} {/* Add Custom Step Button */} )} {/* Custom Step Node */} {currentCustomStep && (
{/* Custom Step Badge */} Custom Step

{currentCustomStep.step_data.title}

{currentCustomStep.step_data.content.instructions && (
)} {currentCustomStep.step_data.content.help_text && (
)} {currentCustomStep.step_data.content.commands && currentCustomStep.step_data.content.commands.length > 0 && (

Commands:

{currentCustomStep.step_data.content.commands.map((cmd, index) => (

{cmd.label}

{cmd.command}
))}
{/* Command Output Capture */}
{commandOutputOpen && (