fix: add type-aware routing for procedural flows

Centralizes tree navigation routing via getTreeNavigatePath helper.
Fixes all pages to route procedural sessions to /flows/:id/navigate
instead of /trees/:id/navigate. Adds safety redirect in troubleshooting
navigator and resume support in procedural navigator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-14 22:04:29 -05:00
parent 505b1c8246
commit 60e52763a7
8 changed files with 101 additions and 10 deletions

View File

@@ -12,7 +12,7 @@ TreeType = Literal['troubleshooting', 'procedural']
# --- Intake Form Schemas ---
FIELD_TYPES = Literal[
'text', 'textarea', 'number', 'ip_address', 'email',
'text', 'textarea', 'number', 'ip_address', 'email', 'url',
'select', 'multi_select', 'checkbox', 'password'
]

View File

@@ -0,0 +1,31 @@
/**
* Shared routing helpers for tree/session navigation.
* Centralizes the logic for determining the correct navigation path
* based on tree type (troubleshooting vs procedural).
*/
/**
* Get the navigation path for starting or resuming a tree/session.
*/
export function getTreeNavigatePath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
return `/flows/${treeId}/navigate`
}
return `/trees/${treeId}/navigate`
}
/**
* Get the editor path for a tree.
*/
export function getTreeEditorPath(
treeId: string,
treeType?: string
): string {
if (treeType === 'procedural') {
return `/flows/${treeId}/edit`
}
return `/trees/${treeId}/edit`
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
@@ -21,6 +21,8 @@ interface StepState {
export function ProceduralNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
const location = useLocation()
const locationState = location.state as { sessionId?: string } | undefined
const [tree, setTree] = useState<Tree | null>(null)
const [session, setSession] = useState<Session | null>(null)
@@ -98,6 +100,12 @@ export function ProceduralNavigationPage() {
}
setTree(treeData)
// If resuming an existing session
if (locationState?.sessionId) {
await resumeSession(treeData, locationState.sessionId)
return
}
// Check if intake form exists
if (treeData.intake_form && treeData.intake_form.length > 0) {
setShowIntakeForm(true)
@@ -134,6 +142,42 @@ export function ProceduralNavigationPage() {
}
}
const resumeSession = async (treeData: Tree, sessionId: string) => {
try {
const sessionData = await sessionsApi.get(sessionId)
setSession(sessionData)
setSessionVariables(sessionData.session_variables || {})
setShowIntakeForm(false)
// Initialize step states from session decisions
const allSteps = getStepsFromTree(treeData)
const initialStates = new Map<string, StepState>()
for (const step of allSteps) {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
// Hydrate completed steps from decisions
for (const decision of sessionData.decisions || []) {
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
initialStates.set(decision.node_id, {
notes: decision.notes || '',
verificationValue: decision.command_output || '',
completedAt: decision.exited_at || decision.timestamp,
})
}
}
setStepStates(initialStates)
// Set current step to first incomplete step
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
} catch {
toast.error('Failed to resume session')
navigate('/my-trees')
}
}
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
return structure.steps || []

View File

@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
import type { Session } from '@/types/session'
import { getTreeNavigatePath } from '@/lib/routing'
function timeAgo(dateStr: string): string {
@@ -27,7 +28,7 @@ export function QuickStartPage() {
const [isSearching, setIsSearching] = useState(false)
const [showResults, setShowResults] = useState(false)
const [activeSessions, setActiveSessions] = useState<Session[]>([])
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([])
const [isLoading, setIsLoading] = useState(true)
const searchRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -44,7 +45,7 @@ export function QuickStartPage() {
// Deduplicate recent sessions by tree_id, max 5
const seen = new Set<string>()
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = []
for (const s of recent) {
if (!seen.has(s.tree_id) && deduped.length < 5) {
seen.add(s.tree_id)
@@ -52,6 +53,7 @@ export function QuickStartPage() {
tree_id: s.tree_id,
name: s.tree_snapshot?.name || 'Unnamed Tree',
lastUsed: s.started_at,
tree_type: s.tree_snapshot?.tree_type,
})
}
}
@@ -164,7 +166,7 @@ export function QuickStartPage() {
{searchResults.map((tree) => (
<li key={tree.id}>
<button
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
>
<div className="text-sm font-medium text-white">
@@ -210,7 +212,7 @@ export function QuickStartPage() {
</div>
<button
onClick={() =>
navigate(`/trees/${activeSessions[0].tree_id}/navigate`, {
navigate(getTreeNavigatePath(activeSessions[0].tree_id, activeSessions[0].tree_snapshot?.tree_type), {
state: { sessionId: activeSessions[0].id },
})
}
@@ -234,7 +236,7 @@ export function QuickStartPage() {
<button
key={session.id}
onClick={() =>
navigate(`/trees/${session.tree_id}/navigate`, {
navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), {
state: { sessionId: session.id },
})
}
@@ -282,7 +284,7 @@ export function QuickStartPage() {
{recentTrees.map((tree) => (
<button
key={tree.tree_id}
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
onClick={() => navigate(getTreeNavigatePath(tree.tree_id, tree.tree_type))}
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
>
<div className="flex items-start justify-between mb-3">

View File

@@ -8,6 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
import type { SessionFilterState } from '@/components/session/SessionFilters'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { getTreeNavigatePath } from '@/lib/routing'
export function SessionHistoryPage() {
const navigate = useNavigate()
@@ -284,7 +285,7 @@ export function SessionHistoryPage() {
</button>
{!session.completed_at && (
<button
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
className={cn(
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
'hover:bg-white/90'

View File

@@ -15,6 +15,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle'
import { SortDropdown } from '@/components/library/SortDropdown'
import { cn, safeGetItem } from '@/lib/utils'
import { getTreeNavigatePath } from '@/lib/routing'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { toast } from '@/lib/toast'
@@ -450,7 +451,7 @@ export function TreeLibraryPage() {
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
>
<Play className="h-3.5 w-3.5" />

View File

@@ -261,6 +261,16 @@ export function TreeNavigationPage() {
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

View File

@@ -39,6 +39,7 @@ export interface TreeSnapshot extends TreeStructure {
description?: string
category?: string
version?: number
tree_type?: string
}
export interface Session {
@@ -58,6 +59,7 @@ export interface Session {
exported: boolean
scratchpad: string
next_steps: string
session_variables: Record<string, string>
}
export interface SessionCreate {