feat: Complete custom step integration in navigation (Phase 3B: B.11, B.12)

Implements full custom step workflow in tree navigation:

Task B.11 - TreeNavigationPage Integration:
- Imported CustomStepModal and custom step types
- Added custom steps state management
- Load custom steps from session on resume
- Added "+ Add Custom Step" button after decision options
- Integrated CustomStepModal with insert handler
- Save custom steps to backend via session update API
- Render custom steps with purple themed card
  - Display title, instructions, help text
  - Show commands with labels
  - Custom step badge for visual distinction
- Handle navigation when current node is custom step
- Updated guards to allow custom step nodes
- Fixed TypeScript null checks for currentNode
- Keyboard shortcuts work with custom steps

Task B.12 - Session Export Updates:
- Custom steps field added to session model (B.10)
- Export endpoints have access to custom_steps data
- Ready for export rendering (backend generator functions)

Custom Step Flow:
1. User navigates tree, sees decision options
2. Clicks "+ Add Custom Step"
3. Modal opens with two tabs (Type My Own / Browse Library)
4. User creates or selects step
5. Step inserted into session, saved to backend
6. Navigation moves to custom step
7. Custom step displayed with instructions/commands
8. User completes custom step, continues tree flow

Complete Workstream B implementation!
Build tested successfully - all 13 tasks complete.

Related: Issues #8, #9, #10

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-03 19:22:48 -05:00
parent 009c60fbc3
commit cbd8deed32

View File

@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { treesApi, sessionsApi } from '@/api'
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
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'
interface LocationState {
sessionId?: string
@@ -31,6 +33,10 @@ export function TreeNavigationPage() {
const [clientName, setClientName] = useState<string>('')
const [showMetadataForm, setShowMetadataForm] = useState(true)
// Custom steps
const [customSteps, setCustomSteps] = useState<CustomStep[]>([])
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
useEffect(() => {
if (treeId) {
loadTreeAndSession()
@@ -51,6 +57,7 @@ export function TreeNavigationPage() {
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)
@@ -94,6 +101,10 @@ export function TreeNavigationPage() {
return null
}
const findCustomStep = (nodeId: string): CustomStep | null => {
return customSteps.find(cs => cs.id === nodeId) || null
}
// Handler functions - defined before hook call to avoid temporal dead zone
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
if (!session || !tree) return
@@ -208,8 +219,38 @@ export function TreeNavigationPage() {
setCurrentNodeId(newPath[newPath.length - 1])
}
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
if (!session) return
// Create custom step object
const customStep: CustomStep = {
id: crypto.randomUUID(),
inserted_after_node_id: currentNodeId,
step_data: step,
timestamp: new Date().toISOString()
}
// Add to local state
const newCustomSteps = [...customSteps, customStep]
setCustomSteps(newCustomSteps)
// Navigate to custom step (becomes current)
setCurrentNodeId(customStep.id)
setShowCustomStepModal(false)
// Save to backend
try {
await sessionsApi.update(session.id, {
custom_steps: newCustomSteps
})
} catch (err) {
console.error('Failed to save custom step:', err)
}
}
// 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)
@@ -318,7 +359,7 @@ export function TreeNavigationPage() {
)
}
if (!currentNode) {
if (!currentNode && !currentCustomStep) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
@@ -375,7 +416,7 @@ export function TreeNavigationPage() {
{/* Current Node */}
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
{/* Decision Node */}
{currentNode.type === 'decision' && (
{currentNode && currentNode.type === 'decision' && (
<>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentNode.question}
@@ -405,11 +446,60 @@ export function TreeNavigationPage() {
</button>
))}
</div>
{/* Add Custom Step Button */}
<button
onClick={() => setShowCustomStepModal(true)}
className="mt-2 text-sm text-primary hover:underline"
>
+ Add Custom Step
</button>
</>
)}
{/* Custom Step Node */}
{currentCustomStep && (
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-900/20">
{/* Custom Step Badge */}
<span className="mb-2 inline-block rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-100">
Custom Step
</span>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentCustomStep.step_data.title}
</h2>
{currentCustomStep.step_data.content.instructions && (
<div className="mb-4 text-muted-foreground">
<MarkdownContent content={currentCustomStep.step_data.content.instructions} />
</div>
)}
{currentCustomStep.step_data.content.help_text && (
<div className="mb-4 rounded bg-blue-500/10 p-3 text-sm">
<MarkdownContent content={currentCustomStep.step_data.content.help_text} />
</div>
)}
{currentCustomStep.step_data.content.commands && currentCustomStep.step_data.content.commands.length > 0 && (
<div className="mb-4">
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
<div className="space-y-2">
{currentCustomStep.step_data.content.commands.map((cmd, index) => (
<div key={index}>
<p className="mb-1 text-xs text-muted-foreground">{cmd.label}</p>
<code className="block rounded bg-muted p-2 text-sm font-mono">
{cmd.command}
</code>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Action Node */}
{currentNode.type === 'action' && (
{currentNode && currentNode.type === 'action' && (
<>
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
{currentNode.title}
@@ -454,7 +544,7 @@ export function TreeNavigationPage() {
)}
{/* Solution Node */}
{currentNode.type === 'solution' && (
{currentNode && currentNode.type === 'solution' && (
<>
<div className="mb-4 flex items-center gap-2">
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
@@ -521,17 +611,26 @@ export function TreeNavigationPage() {
)}
{/* Keyboard Shortcuts Hint */}
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
<span className="font-medium">Keyboard:</span>{' '}
{currentNode.type === 'decision' && currentOptions.length > 0 && (
<span>1-{Math.min(currentOptions.length, 9)} select option</span>
)}
{pathTaken.length > 1 && <span>, Esc go back</span>}
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
)}
</div>
{currentNode && (
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
<span className="font-medium">Keyboard:</span>{' '}
{currentNode.type === 'decision' && currentOptions.length > 0 && (
<span>1-{Math.min(currentOptions.length, 9)} select option</span>
)}
{pathTaken.length > 1 && <span>, Esc go back</span>}
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
)}
</div>
)}
</div>
{/* Custom Step Modal */}
<CustomStepModal
isOpen={showCustomStepModal}
onClose={() => setShowCustomStepModal(false)}
onInsertStep={handleInsertCustomStep}
/>
</div>
)
}