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:
@@ -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'
|
||||
]
|
||||
|
||||
|
||||
31
frontend/src/lib/routing.ts
Normal file
31
frontend/src/lib/routing.ts
Normal 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`
|
||||
}
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user