import { useEffect, useState } from 'react' import { useParams, useNavigate, useLocation } from 'react-router-dom' import { treesApi, sessionsApi, stepsApi } from '@/api' import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types' import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' import { cn } from '@/lib/utils' import { MarkdownContent } from '@/components/ui/MarkdownContent' import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session' import { Plus, CheckCircle, ArrowRight } from 'lucide-react' interface LocationState { sessionId?: string } 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 [notes, setNotes] = useState('') const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isCompleting, setIsCompleting] = useState(false) // Session metadata const [ticketNumber, setTicketNumber] = useState('') const [clientName, setClientName] = useState('') const [showMetadataForm, setShowMetadataForm] = useState(true) // Custom steps const [customSteps, setCustomSteps] = useState([]) const [showCustomStepModal, setShowCustomStepModal] = useState(false) // Post-step action flow const [showPostStepModal, setShowPostStepModal] = useState(false) const [pendingStep, setPendingStep] = useState(null) const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false) const [isSavingStep, setIsSavingStep] = useState(false) // Continuation flow const [showContinuationModal, setShowContinuationModal] = useState(false) const [branchOriginNodeId, setBranchOriginNodeId] = useState(null) const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState(null) // Custom branch mode const [customBranchMode, setCustomBranchMode] = useState(false) // Fork flow const [showForkModal, setShowForkModal] = useState(false) // Scratchpad save handler 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!) 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[]) setCustomSteps(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) setShowMetadataForm(false) } catch (err) { setError('Failed to start session') console.error(err) } finally { setIsLoading(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 findCustomStep = (nodeId: string): CustomStep | null => { return customSteps.find(cs => cs.id === nodeId) || null } // Get descendant nodes two levels deep (grandchildren) from a decision node's options. // This skips the immediate children (which often mirror the option labels) and shows // the actual next-level nodes the user would encounter further down each path. const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => { const decisionNode = findNode(decisionNodeId, tree?.tree_structure) if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) { return [] } const descendants: DescendantNode[] = [] for (const option of decisionNode.options) { if (!option.next_node_id) continue const childNode = findNode(option.next_node_id, tree?.tree_structure) if (!childNode) continue // Go one level deeper: get the grandchildren if (childNode.type === 'decision' && childNode.options) { for (const childOption of childNode.options) { if (!childOption.next_node_id) continue const grandchild = findNode(childOption.next_node_id, tree?.tree_structure) if (!grandchild) continue descendants.push({ id: grandchild.id, label: grandchild.question || grandchild.title || 'Untitled', type: grandchild.type, parentOptionLabel: `${option.label} \u2192 ${childOption.label}` }) } } else if (childNode.type === 'action' && childNode.next_node_id) { const grandchild = findNode(childNode.next_node_id, tree?.tree_structure) if (grandchild) { descendants.push({ id: grandchild.id, label: grandchild.question || grandchild.title || 'Untitled', type: grandchild.type, parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}` }) } } // Solution children have no further descendants - skip them } return descendants } // Handler functions - defined before hook call to avoid temporal dead zone const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => { if (!session || !tree) return const node = findNode(currentNodeId, tree.tree_structure) if (!node) return const newDecision: DecisionRecord = { node_id: currentNodeId, question: node.question || null, answer: optionLabel, action_performed: null, notes: notes || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [], } const newPath = [...pathTaken, nextNodeId] const newDecisions = [...decisions, newDecision] setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(nextNodeId) setNotes('') // Update session on backend try { await sessionsApi.update(session.id, { path_taken: newPath, decisions: newDecisions, }) } catch (err) { console.error('Failed to update session:', err) } } const handleContinue = async (actionPerformed?: string) => { if (!session || !tree) return const node = findNode(currentNodeId, tree.tree_structure) if (!node || !node.next_node_id) return const newDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, action_performed: actionPerformed || node.title || 'Action completed', notes: notes || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [], } const newPath = [...pathTaken, node.next_node_id] const newDecisions = [...decisions, newDecision] setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(node.next_node_id) setNotes('') 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 setIsCompleting(true) setError(null) try { // Add final decision const node = findNode(currentNodeId, tree.tree_structure) if (node) { const finalDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, action_performed: node.title || 'Session completed', notes: notes || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [], } await sessionsApi.update(session.id, { decisions: [...decisions, finalDecision], }) } await sessionsApi.complete(session.id) 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 newDecisions = decisions.slice(0, -1) setPathTaken(newPath) setDecisions(newDecisions) setCurrentNodeId(newPath[newPath.length - 1]) } // Navigate back to a previously-created custom step from the decision node const handleNavigateToCustomStep = (customStep: CustomStep) => { const newPath = [...pathTaken, customStep.id] setPathTaken(newPath) setCurrentNodeId(customStep.id) } // Called when CustomStepModal submits - show action modal instead of inserting directly const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => { setPendingStep(step) setPendingStepIsFromLibrary(isFromLibrary) setShowCustomStepModal(false) setShowPostStepModal(true) } const resetPendingStep = () => { setShowPostStepModal(false) setPendingStep(null) setPendingStepIsFromLibrary(false) setIsSavingStep(false) } // Save to library only, don't insert into session const handleSaveForLater = async () => { if (!pendingStep) return setIsSavingStep(true) try { if (!pendingStepIsFromLibrary) { await stepsApi.create({ title: pendingStep.title, step_type: pendingStep.step_type, content: pendingStep.content, visibility: 'private', tags: pendingStep.tags || [] }) } resetPendingStep() } catch (err) { console.error('Failed to save step to library:', err) setIsSavingStep(false) } } // Insert into session and show continuation options const handleUseNow = async () => { if (!pendingStep || !session) return setIsSavingStep(true) try { // Remember where we branched from (for showing descendants later) setBranchOriginNodeId(currentNodeId) // Create custom step object const customStep: CustomStep = { id: crypto.randomUUID(), inserted_after_node_id: currentNodeId, step_data: pendingStep, timestamp: new Date().toISOString() } // Record decision (so it appears in exports) const newDecision: DecisionRecord = { node_id: customStep.id, question: null, answer: null, action_performed: `Custom Step: ${pendingStep.title}`, notes: pendingStep.content.instructions || null, automation_used: false, timestamp: new Date().toISOString(), attachments: [] } // Update state const newCustomSteps = [...customSteps, customStep] const newDecisions = [...decisions, newDecision] const newPath = [...pathTaken, customStep.id] setCustomSteps(newCustomSteps) setDecisions(newDecisions) setPathTaken(newPath) setCurrentNodeId(customStep.id) // Persist to backend await sessionsApi.update(session.id, { path_taken: newPath, decisions: newDecisions, custom_steps: newCustomSteps }) resetPendingStep() // Show continuation modal setShowContinuationModal(true) } catch (err) { console.error('Failed to insert custom step:', err) setIsSavingStep(false) } } // Save to library AND insert into session const handleBoth = async () => { if (!pendingStep) return setIsSavingStep(true) try { // Save to library first if (!pendingStepIsFromLibrary) { await stepsApi.create({ title: pendingStep.title, step_type: pendingStep.step_type, content: pendingStep.content, visibility: 'private', tags: pendingStep.tags || [] }) } // Then use now (this will handle the rest and reset pending step) await handleUseNow() } catch (err) { console.error('Failed to save and insert step:', err) setIsSavingStep(false) } } // Handle selecting a descendant node from continuation modal. // Stores the selection so the user can write notes on their custom step first. const handleSelectDescendant = (nodeId: string) => { setShowContinuationModal(false) setBranchOriginNodeId(null) setPendingContinuationNodeId(nodeId) } // Navigate to the previously-selected descendant node const handleContinueToDescendant = async () => { if (!pendingContinuationNodeId || !session) return const newPath = [...pathTaken, pendingContinuationNodeId] setPathTaken(newPath) setCurrentNodeId(pendingContinuationNodeId) setNotes('') setPendingContinuationNodeId(null) try { await sessionsApi.update(session.id, { path_taken: newPath }) } catch (err) { console.error('Failed to update session path:', err) } } // Enter custom branch building mode const handleBuildCustomBranch = () => { setShowContinuationModal(false) setCustomBranchMode(true) } // Complete session from custom branch mode const handleCustomBranchComplete = async () => { if (!session) return setIsCompleting(true) setError(null) try { // Record completion decision const completionDecision: DecisionRecord = { node_id: currentNodeId, question: null, answer: null, action_performed: 'Custom Branch Completed', notes: notes || 'Issue resolved via custom troubleshooting steps', automation_used: false, timestamp: new Date().toISOString(), attachments: [] } await sessionsApi.update(session.id, { decisions: [...decisions, completionDecision] }) await sessionsApi.complete(session.id) // Show fork modal if custom steps exist if (customSteps.length > 0) { setShowForkModal(true) } else { navigate(`/sessions/${session.id}`) } } catch (err) { console.error('Failed to complete session:', err) setError('Failed to complete session. Please try again.') } finally { setIsCompleting(false) } } // Fork tree with custom branch const handleForkTree = async (name: string, description: string) => { if (!tree) return try { // Create a forked tree structure // For now, we'll create a simple copy - the custom steps are documented in the session const forkedTree = await treesApi.create({ name, description, tree_structure: tree.tree_structure, // Base structure is_public: false }) navigate(`/trees/${forkedTree.id}/edit`) } catch (err) { console.error('Failed to fork tree:', err) throw err } } // Skip forking and go to session detail const handleSkipFork = () => { setShowForkModal(false) navigate(`/sessions/${session!.id}`) } // 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 = 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) { handleSelectOption(option.id, option.label, option.next_node_id) } }, onGoBack: handleGoBack, 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, canContinue: !showMetadataForm && !isLoading && (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-input bg-background px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' )} />
setClientName(e.target.value)} placeholder="e.g., Acme Corp" className={cn( 'mt-1 block w-full rounded-md border border-input bg-background px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' )} />
) } if (!currentNode && !currentCustomStep) { return (
Invalid tree structure
) } return (
{/* Main Content */}
{/* Header */}

{tree.name}

{(ticketNumber || clientName) && (

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

)}
{/* Breadcrumb */}
{pathTaken.map((nodeId, index) => { const node = findNode(nodeId, tree?.tree_structure) const customStep = findCustomStep(nodeId) const label = node?.question || node?.title || customStep?.step_data.title || nodeId return ( {index > 0 && } {label.length > 30 ? `${label.slice(0, 30)}...` : label} ) })}
{/* Current Node */}
{/* Decision Node */} {currentNode && currentNode.type === 'decision' && ( <>

{currentNode.question}

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

Your Custom Steps

{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}
))}
)} {/* Continue to selected descendant */} {pendingContinuationNodeId && !customBranchMode && (() => { const targetNode = findNode(pendingContinuationNodeId, tree?.tree_structure) const targetLabel = targetNode?.question || targetNode?.title || 'next step' return (
) })()} {/* Custom Branch Controls */} {customBranchMode && (

Building custom branch - add steps until the issue is resolved

)}
)} {/* Action Node */} {currentNode && currentNode.type === 'action' && ( <>

{currentNode.title}

{currentNode.description && (
)} {currentNode.commands && currentNode.commands.length > 0 && (

Commands:

{currentNode.commands.map((cmd, index) => ( {cmd} ))}
)} {currentNode.expected_outcome && (

Expected outcome: {currentNode.expected_outcome}

)} {currentNode.next_node_id && ( )} )} {/* Solution Node */} {currentNode && currentNode.type === 'solution' && ( <>
Solution

{currentNode.title}

{currentNode.description && (
)} {currentNode.resolution_steps && currentNode.resolution_steps.length > 0 && (

Resolution steps:

    {currentNode.resolution_steps.map((step, index) => (
  1. {step}
  2. ))}
)} )} {/* Notes */}