feat: add procedural flows with intake forms, navigation, and seed templates

Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 04:13:52 -05:00
parent 303570ca2c
commit 350c977eda
58 changed files with 11686 additions and 167 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus } from 'lucide-react'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
@@ -30,6 +30,7 @@ export function MyTreesPage() {
const [isDeleting, setIsDeleting] = useState(false)
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showCreateMenu, setShowCreateMenu] = useState(false)
useEffect(() => {
loadMyTrees()
@@ -62,15 +63,23 @@ export function MyTreesPage() {
setTrees(treesWithStats)
} catch (err) {
toast.error('Failed to load your trees')
toast.error('Failed to load your flows')
console.error(err)
} finally {
setIsLoading(false)
}
}
const handleStartSession = (treeId: string) => {
navigate(`/trees/${treeId}/navigate`)
const handleStartSession = (tree: TreeWithStats) => {
if (tree.tree_type === 'procedural') {
navigate(`/flows/${tree.id}/navigate`)
} else {
navigate(`/trees/${tree.id}/navigate`)
}
}
const getEditPath = (tree: TreeWithStats) => {
return tree.tree_type === 'procedural' ? `/flows/${tree.id}/edit` : `/trees/${tree.id}/edit`
}
const handleDeleteTree = async () => {
@@ -79,10 +88,10 @@ export function MyTreesPage() {
try {
await treesApi.delete(treeToDelete.id)
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
toast.success(`Tree "${treeToDelete.name}" deleted successfully`)
toast.success(`"${treeToDelete.name}" deleted successfully`)
} catch (err) {
console.error('Failed to delete tree:', err)
toast.error('Failed to delete tree')
console.error('Failed to delete flow:', err)
toast.error('Failed to delete flow')
} finally {
setIsDeleting(false)
setShowDeleteConfirm(false)
@@ -103,19 +112,51 @@ export function MyTreesPage() {
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">My Trees</h1>
<h1 className="text-2xl font-bold text-white sm:text-3xl">My Flows</h1>
<p className="mt-2 text-white/40">
Your forked and custom decision trees
Your forked and custom flows
</p>
</div>
{canCreateTrees && (
<Link
to="/trees/new"
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
<Plus className="h-4 w-4" />
Create Tree
</Link>
<div className="relative">
<button
onClick={() => setShowCreateMenu(!showCreateMenu)}
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
<Plus className="h-4 w-4" />
Create New
<ChevronDown className="h-3.5 w-3.5" />
</button>
{showCreateMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-white/10 bg-black/95 p-1 shadow-xl backdrop-blur-sm">
<Link
to="/trees/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-white hover:bg-white/10"
>
<FolderTree className="h-4 w-4 text-white/50" />
<div>
<div className="font-medium">Troubleshooting Tree</div>
<div className="text-xs text-white/40">Branching decision flow</div>
</div>
</Link>
<Link
to="/flows/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-white hover:bg-white/10"
>
<ListOrdered className="h-4 w-4 text-white/50" />
<div>
<div className="font-medium">Procedural Flow</div>
<div className="text-xs text-white/40">Step-by-step procedure</div>
</div>
</Link>
</div>
</>
)}
</div>
)}
</div>
@@ -127,9 +168,9 @@ export function MyTreesPage() {
) : trees.length === 0 ? (
<div className="rounded-lg border border-dashed border-white/10 bg-white/[0.02] px-4 py-12 text-center">
<FolderTree className="mx-auto mb-4 h-12 w-12 text-white/20" />
<h2 className="mb-2 text-lg font-semibold text-white">No personal trees yet</h2>
<h2 className="mb-2 text-lg font-semibold text-white">No personal flows yet</h2>
<p className="mb-4 text-sm text-white/40">
Fork a tree from the library to customize it for your workflow
Fork a flow from the library to customize it for your workflow
</p>
<div className="flex items-center justify-center gap-3">
<Link
@@ -164,12 +205,24 @@ export function MyTreesPage() {
>
{/* Header */}
<div className="mb-3 flex items-start justify-between gap-2">
<h3 className="font-semibold text-white">{tree.name}</h3>
{tree.category_info && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
{tree.category_info.name}
</span>
)}
<div className="flex items-center gap-2">
{tree.tree_type === 'procedural' && (
<ListOrdered className="h-4 w-4 shrink-0 text-white/40" />
)}
<h3 className="font-semibold text-white">{tree.name}</h3>
</div>
<div className="flex items-center gap-1.5">
{tree.tree_type === 'procedural' && (
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] font-medium text-blue-400">
Procedure
</span>
)}
{tree.category_info && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
{tree.category_info.name}
</span>
)}
</div>
</div>
{/* Description */}
@@ -216,7 +269,7 @@ export function MyTreesPage() {
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleStartSession(tree.id)}
onClick={() => handleStartSession(tree)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
'hover:bg-white/90'
@@ -227,7 +280,7 @@ export function MyTreesPage() {
</button>
{canEditTree({ author_id: tree.author_id, account_id: tree.account_id }) && (
<Link
to={`/trees/${tree.id}/edit`}
to={getEditPath(tree)}
className={cn(
'rounded-md border border-white/10 p-2 text-white/40',
'hover:bg-white/10 hover:text-white'
@@ -279,7 +332,7 @@ export function MyTreesPage() {
setTreeToDelete(null)
}}
onConfirm={handleDeleteTree}
title="Delete Tree"
title="Delete Flow"
message={`Are you sure you want to delete "${treeToDelete?.name}"? This action can be undone by an administrator.`}
confirmLabel="Delete"
confirmVariant="destructive"

View File

@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
import { StepList } from '@/components/procedural-editor/StepList'
import { toast } from '@/lib/toast'
export function ProceduralEditorPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEditMode = !!id
const {
treeId,
name,
description,
tags,
isPublic,
isDirty,
isSaving,
isLoading,
initNew,
loadTree,
reset,
setName,
setDescription,
setTags,
setIsPublic,
setIsSaving,
markSaved,
getTreeForSave,
} = useProceduralEditorStore()
const [tagInput, setTagInput] = useState('')
// Load tree or init new
useEffect(() => {
if (isEditMode && id) {
loadExistingTree(id)
} else {
initNew()
}
return () => { reset() }
}, [id])
const loadExistingTree = async (treeId: string) => {
try {
const tree = await treesApi.get(treeId)
if (tree.tree_type !== 'procedural') {
toast.error('This tree is not a procedural flow')
navigate('/my-trees')
return
}
loadTree(tree)
} catch {
toast.error('Failed to load procedure')
navigate('/my-trees')
}
}
const handleSave = async (saveStatus?: 'draft' | 'published') => {
if (!name.trim()) {
toast.error('Please enter a name for the procedure')
return
}
setIsSaving(true)
try {
const payload = getTreeForSave()
if (saveStatus) {
payload.status = saveStatus
}
if (isEditMode && treeId) {
await treesApi.update(treeId, payload)
markSaved()
toast.success('Procedure saved')
} else {
const created = await treesApi.create(payload)
markSaved()
toast.success('Procedure created')
navigate(`/flows/${created.id}/edit`, { replace: true })
}
} catch (err: unknown) {
const message = err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string | { message?: string } } } }).response?.data?.detail
: null
const errorText = typeof message === 'string' ? message : typeof message === 'object' && message?.message ? message.message : 'Failed to save procedure'
toast.error(errorText)
} finally {
setIsSaving(false)
}
}
const handleAddTag = () => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
setTags([...tags, tag])
setTagInput('')
}
}
const handleRemoveTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag))
}
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
</div>
)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/my-trees')}
className="rounded-md p-2 text-white/40 hover:bg-white/10 hover:text-white"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<ListOrdered className="h-5 w-5 text-white/50" />
<h1 className="text-xl font-bold text-white sm:text-2xl">
{isEditMode ? 'Edit Procedure' : 'New Procedure'}
</h1>
</div>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-white/40">Unsaved changes</span>
)}
<button
onClick={() => handleSave('draft')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white disabled:opacity-50"
>
Save Draft
</button>
<button
onClick={() => handleSave('published')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Publish'}
</button>
</div>
</div>
{/* Content */}
<div className="space-y-6">
{/* Metadata */}
<div className="glass-card rounded-2xl p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Details</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white/60">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Domain Controller Build"
className="w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white/60">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this procedure..."
rows={2}
className="w-full rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
<div className="flex items-center gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
placeholder="Add tag..."
className="flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="text-white/40 hover:text-white"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="rounded border-white/20"
/>
Public (visible to all users)
</label>
</div>
</div>
</div>
</div>
{/* Intake Form Builder */}
<IntakeFormBuilder />
{/* Step List */}
<StepList />
</div>
</div>
)
}
export default ProceduralEditorPage

View File

@@ -0,0 +1,402 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types'
import { IntakeFormModal } from '@/components/procedural/IntakeFormModal'
import { StepChecklist } from '@/components/procedural/StepChecklist'
import { StepDetail } from '@/components/procedural/StepDetail'
import { ProgressBar } from '@/components/procedural/ProgressBar'
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface StepState {
notes: string
verificationValue: string
completedAt: string | null
}
export function ProceduralNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
const [tree, setTree] = useState<Tree | null>(null)
const [session, setSession] = useState<Session | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [showIntakeForm, setShowIntakeForm] = useState(false)
const [sessionVariables, setSessionVariables] = useState<Record<string, string>>({})
const [currentStepIndex, setCurrentStepIndex] = useState(0)
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
const [isComplete, setIsComplete] = useState(false)
const [completedAt, setCompletedAt] = useState<string>('')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [paramsOpen, setParamsOpen] = useState(false)
const [elapsedMinutes, setElapsedMinutes] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Get procedural steps from tree
const getSteps = (): ProceduralStep[] => {
if (!tree) return []
const structure = tree.tree_structure as unknown as { steps?: ProceduralStep[] }
return structure.steps || []
}
const steps = getSteps()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
const completedStepIds = new Set(
Array.from(stepStates.entries())
.filter(([, state]) => state.completedAt)
.map(([id]) => id)
)
const estimatedTotalMinutes = procedureSteps.reduce(
(sum, step) => sum + (step.estimated_minutes || 0),
0
)
// Load tree
useEffect(() => {
if (!treeId) return
loadTree(treeId)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [treeId])
// Parse backend timestamp — ensure UTC if no timezone info
const parseTimestamp = (ts: string) => {
if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) {
return new Date(ts + 'Z')
}
return new Date(ts)
}
// Elapsed time timer
useEffect(() => {
if (session && !isComplete) {
const calcElapsed = () => {
const start = parseTimestamp(session.started_at).getTime()
setElapsedMinutes(Math.max(0, Math.floor((Date.now() - start) / 60000)))
}
calcElapsed()
timerRef.current = setInterval(calcElapsed, 30000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [session, isComplete])
const loadTree = async (id: string) => {
setIsLoading(true)
try {
const treeData = await treesApi.get(id)
if (treeData.tree_type !== 'procedural') {
navigate(`/trees/${id}/navigate`, { replace: true })
return
}
setTree(treeData)
// Check if intake form exists
if (treeData.intake_form && treeData.intake_form.length > 0) {
setShowIntakeForm(true)
} else {
await startSession(id, {})
}
} catch {
toast.error('Failed to load procedure')
navigate('/my-trees')
} finally {
setIsLoading(false)
}
}
const startSession = async (id: string, variables: Record<string, string>) => {
try {
const newSession = await sessionsApi.create({
tree_id: id,
session_variables: Object.keys(variables).length > 0 ? variables : undefined,
})
setSession(newSession)
setSessionVariables(variables)
setShowIntakeForm(false)
// Initialize step states
const initialStates = new Map<string, StepState>()
const allSteps = getStepsFromTree(tree!)
for (const step of allSteps) {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
setStepStates(initialStates)
} catch {
toast.error('Failed to start session')
}
}
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
return structure.steps || []
}
const handleIntakeSubmit = async (variables: Record<string, string>) => {
if (!treeId) return
await startSession(treeId, variables)
}
const handleMarkComplete = async () => {
if (!session || procedureSteps.length === 0) return
const currentStep = procedureSteps[currentStepIndex]
if (!currentStep) return
const now = new Date().toISOString()
// Update step state
setStepStates((prev) => {
const next = new Map(prev)
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
next.set(currentStep.id, { ...existing, completedAt: now })
return next
})
// Create a decision record for this step
const stepState = stepStates.get(currentStep.id)
const decision: DecisionRecord = {
node_id: currentStep.id,
question: currentStep.title,
answer: 'completed',
action_performed: currentStep.description || null,
notes: stepState?.notes || null,
command_output: stepState?.verificationValue || null,
automation_used: false,
timestamp: now,
entered_at: null,
exited_at: now,
duration_seconds: null,
attachments: [],
}
try {
const updatedDecisions = [...(session.decisions || []), decision]
await sessionsApi.update(session.id, {
decisions: updatedDecisions,
path_taken: [...(session.path_taken || []), currentStep.id],
})
setSession((prev) => prev ? {
...prev,
decisions: updatedDecisions,
path_taken: [...(prev.path_taken || []), currentStep.id],
} : prev)
// Move to next step or complete
if (currentStepIndex >= procedureSteps.length - 1) {
// Last step — complete the procedure
const completedTime = new Date().toISOString()
await sessionsApi.complete(session.id, {
outcome: 'resolved',
outcome_notes: `Procedure completed. ${procedureSteps.length} steps finished.`,
})
setCompletedAt(completedTime)
setIsComplete(true)
} else {
setCurrentStepIndex(currentStepIndex + 1)
}
} catch {
toast.error('Failed to save progress')
}
}
const handleStepNotesChange = (notes: string) => {
const currentStep = procedureSteps[currentStepIndex]
if (!currentStep) return
setStepStates((prev) => {
const next = new Map(prev)
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
next.set(currentStep.id, { ...existing, notes })
return next
})
}
const handleVerificationChange = (value: string) => {
const currentStep = procedureSteps[currentStepIndex]
if (!currentStep) return
setStepStates((prev) => {
const next = new Map(prev)
const existing = next.get(currentStep.id) || { notes: '', verificationValue: '', completedAt: null }
next.set(currentStep.id, { ...existing, verificationValue: value })
return next
})
}
// Loading state
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
</div>
)
}
// Intake form modal
if (showIntakeForm && tree) {
return (
<IntakeFormModal
isOpen={true}
fields={tree.intake_form || []}
treeName={tree.name}
onSubmit={handleIntakeSubmit}
onCancel={() => navigate('/my-trees')}
/>
)
}
// Completion summary
if (isComplete && tree && session) {
return (
<div className="container mx-auto px-4 py-8 sm:px-6">
<CompletionSummary
treeName={tree.name}
steps={steps}
completions={new Map(
Array.from(stepStates.entries())
.filter(([, s]) => s.completedAt)
.map(([id, s]) => [id, {
stepId: id,
notes: s.notes,
verificationValue: s.verificationValue,
completedAt: s.completedAt!,
}])
)}
variables={sessionVariables}
startedAt={session.started_at}
completedAt={completedAt}
onExport={() => navigate(`/sessions/${session.id}`)}
onClose={() => navigate('/my-trees')}
/>
</div>
)
}
// No session yet
if (!session || !tree) return null
const currentStep = procedureSteps[currentStepIndex]
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Top bar */}
<div className="border-b border-white/[0.06] px-4 py-3 sm:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="rounded-md p-1.5 text-white/40 hover:bg-white/10 hover:text-white lg:hidden"
>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<ListOrdered className="h-5 w-5 text-white/40" />
<h1 className="text-sm font-semibold text-white sm:text-base">{tree.name}</h1>
</div>
</div>
<div className="mt-2">
<ProgressBar
currentStep={completedStepIds.size}
totalSteps={procedureSteps.length}
elapsedMinutes={elapsedMinutes}
estimatedTotalMinutes={estimatedTotalMinutes || undefined}
/>
</div>
</div>
{/* Main content */}
<div className="flex min-h-0 flex-1">
{/* Left sidebar - step checklist */}
<div
className={cn(
'border-r border-white/[0.06] bg-black/30 transition-all duration-200',
sidebarOpen ? 'w-72 p-3' : 'w-0 overflow-hidden p-0'
)}
>
{sidebarOpen && (
<>
<StepChecklist
steps={steps}
currentStepIndex={currentStepIndex}
completedStepIds={completedStepIds}
onStepClick={setCurrentStepIndex}
/>
{/* View Parameters button */}
{Object.keys(sessionVariables).length > 0 && (
<div className="mt-3 border-t border-white/[0.06] pt-3">
<button
onClick={() => setParamsOpen(true)}
className="flex w-full items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/40 hover:bg-white/[0.06] hover:text-white/60"
>
<Settings2 className="h-3.5 w-3.5" />
View Parameters ({Object.keys(sessionVariables).length})
</button>
</div>
)}
</>
)}
</div>
{/* Right panel - step detail */}
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
{currentStep && (
<StepDetail
step={currentStep}
stepNumber={currentStepIndex + 1}
totalSteps={procedureSteps.length}
variables={sessionVariables}
notes={currentStepState?.notes || ''}
onNotesChange={handleStepNotesChange}
verificationValue={currentStepState?.verificationValue || ''}
onVerificationChange={handleVerificationChange}
isCompleted={completedStepIds.has(currentStep.id)}
onMarkComplete={handleMarkComplete}
isLast={currentStepIndex === procedureSteps.length - 1}
/>
)}
</div>
</div>
{/* Parameters popover */}
{paramsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setParamsOpen(false)}
/>
<div className="relative w-full max-w-md rounded-2xl border border-white/10 bg-black/95 shadow-2xl backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<h3 className="text-sm font-semibold text-white">Project Parameters</h3>
<button
onClick={() => setParamsOpen(false)}
className="rounded-lg p-1 text-white/40 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-5">
<div className="space-y-2">
{Object.entries(sessionVariables).map(([key, value]) => (
<div key={key} className="flex items-baseline justify-between gap-4 rounded-lg bg-white/[0.03] px-3 py-2">
<span className="text-xs font-medium text-white/40">{key.replace(/_/g, ' ')}</span>
<span className="text-right text-sm text-white/70">{value || 'N/A'}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default ProceduralNavigationPage

View File

@@ -124,7 +124,7 @@ export function QuickStartPage() {
{/* Description */}
<p className="text-lg text-white/40 mb-10 max-w-2xl mx-auto leading-relaxed">
Search our library of proven decision trees or continue where you left off
Search our library of proven flows or continue where you left off
</p>
{/* Search Bar */}
@@ -139,7 +139,7 @@ export function QuickStartPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.length >= 2 && setShowResults(true)}
placeholder="Paste ticket subject or search for a tree..."
placeholder="Paste ticket subject or search for a flow..."
className="flex-1 bg-transparent py-4 px-4 text-white placeholder:text-white/30 focus:outline-none"
/>
{isSearching && (
@@ -270,7 +270,7 @@ export function QuickStartPage() {
{!isLoading && recentTrees.length > 0 && (
<div className="mx-auto max-w-4xl mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">Recent Trees</h2>
<h2 className="text-2xl font-bold text-white">Recent Flows</h2>
<Link
to="/trees"
className="text-sm text-white/60 hover:text-white font-medium transition-colors"
@@ -309,7 +309,7 @@ export function QuickStartPage() {
to="/trees"
className="inline-flex items-center gap-2 px-6 py-3 bg-white/10 border border-white/20 text-white font-medium rounded-xl hover:bg-white/20 transition-all"
>
Browse All Trees
Browse All Flows
<ArrowRight className="h-4 w-4" />
</Link>
</div>

View File

@@ -481,83 +481,151 @@ export function SessionDetailPage() {
</div>
</div>
{/* Timeline */}
{/* Timeline / Step Checklist */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-white" />
<span className="text-white/40">
Session started: {formatDate(session.started_at)}
</span>
</div>
{session.decisions.map((decision, index) => (
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
<div className="relative">
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
<div className="glass-card rounded-xl p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{decision.question && (
<p className="font-medium text-white">{decision.question}</p>
)}
{decision.answer && (
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
)}
{decision.action_performed && (
<p className="mt-1 text-sm text-white/40">
Action: {decision.action_performed}
</p>
)}
{decision.notes && (
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
{decision.command_output}
</pre>
</div>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)}
</p>
{(session.tree_snapshot as unknown as Record<string, unknown>).tree_type === 'procedural' ? (
<>
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
<div className="space-y-2">
{session.decisions.map((decision, index) => {
const isCompleted = decision.answer === 'completed'
return (
<div
key={index}
className={cn(
'glass-card rounded-xl p-4',
isCompleted && 'border-l-2 border-emerald-400/50'
)}
>
<div className="flex items-start gap-3">
<span className={cn(
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
)}>
{isCompleted ? '\u2713' : index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-white">{decision.question || 'Step'}</p>
{decision.notes && (
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<p className="mt-1 text-sm text-white/40">
Verification: {decision.command_output}
</p>
)}
{decision.duration_seconds != null && (
<p className="mt-1 text-xs text-white/30">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
</div>
<button
onClick={() => handleCopyStep(decision, index)}
title="Copy step to clipboard"
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
>
{copiedStepIndex === index ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
)
})}
{session.completed_at && (
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
<span className="h-3 w-3 rounded-full bg-emerald-500" />
<span className="text-emerald-400">
Procedure completed: {formatDate(session.completed_at)}
</span>
</div>
)}
</div>
</>
) : (
<>
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-white" />
<span className="text-white/40">
Session started: {formatDate(session.started_at)}
</span>
</div>
{session.decisions.map((decision, index) => (
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
<div className="relative">
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
<div className="glass-card rounded-xl p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
{decision.question && (
<p className="font-medium text-white">{decision.question}</p>
)}
{decision.answer && (
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
)}
{decision.action_performed && (
<p className="mt-1 text-sm text-white/40">
Action: {decision.action_performed}
</p>
)}
{decision.notes && (
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
Notes: {decision.notes}
</p>
)}
{decision.command_output && (
<div className="mt-2">
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
{decision.command_output}
</pre>
</div>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)}
</p>
</div>
<button
onClick={() => handleCopyStep(decision, index)}
title="Copy step to clipboard"
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
>
{copiedStepIndex === index ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<button
onClick={() => handleCopyStep(decision, index)}
title="Copy step to clipboard"
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
>
{copiedStepIndex === index ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
</div>
))}
))}
{session.completed_at && (
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-emerald-400">
Session completed: {formatDate(session.completed_at)}
</span>
{session.completed_at && (
<div className="flex items-center gap-3 text-sm">
<span className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-emerald-400">
Session completed: {formatDate(session.completed_at)}
</span>
</div>
)}
</div>
)}
</div>
</>
)}
</div>
{/* Export Preview Modal */}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
import { Plus, X, FolderOpen, RotateCcw, Play } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
@@ -22,6 +22,7 @@ import { toast } from '@/lib/toast'
export function TreeLibraryPage() {
const { canCreateTrees } = usePermissions()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const [trees, setTrees] = useState<TreeListItem[]>([])
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [folders, setFolders] = useState<FolderListItem[]>([])
@@ -32,6 +33,22 @@ export function TreeLibraryPage() {
const [isLoading, setIsLoading] = useState(true)
const [showDrafts, setShowDrafts] = useState(false)
// Read type filter from URL query params (e.g. /trees?type=procedural)
const urlType = searchParams.get('type')
const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural'>(
urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all'
)
// Sync type filter when URL changes (e.g. clicking nav sub-items)
useEffect(() => {
const t = searchParams.get('type')
if (t === 'troubleshooting' || t === 'procedural') {
setTypeFilter(t)
} else {
setTypeFilter('all')
}
}, [searchParams])
// View preferences from store
const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } =
useUserPreferencesStore()
@@ -112,7 +129,7 @@ export function TreeLibraryPage() {
// Load trees when filters change
useEffect(() => {
loadTrees()
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts])
}, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, showDrafts, typeFilter])
// Load folders on mount and listen for changes
useEffect(() => {
@@ -126,6 +143,7 @@ export function TreeLibraryPage() {
setIsLoading(true)
try {
const treesData = await treesApi.list({
tree_type: typeFilter !== 'all' ? typeFilter : undefined,
category_id: selectedCategoryId || undefined,
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
folder_id: selectedFolderId || undefined,
@@ -134,7 +152,7 @@ export function TreeLibraryPage() {
})
setTrees(treesData)
} catch (err) {
toast.error('Failed to load trees')
toast.error('Failed to load flows')
console.error(err)
} finally {
setIsLoading(false)
@@ -151,7 +169,7 @@ export function TreeLibraryPage() {
const results = await treesApi.search(searchQuery)
setTrees(results)
} catch (err) {
toast.error('Failed to search trees')
toast.error('Failed to search flows')
console.error(err)
} finally {
setIsLoading(false)
@@ -175,8 +193,12 @@ export function TreeLibraryPage() {
setSearchQuery('')
}
const handleStartSession = (treeId: string) => {
navigate(`/trees/${treeId}/navigate`)
const handleStartSession = (treeId: string, treeType?: string) => {
if (treeType === 'procedural') {
navigate(`/flows/${treeId}/navigate`)
} else {
navigate(`/trees/${treeId}/navigate`)
}
}
const handleCreateFolder = (parentId?: string | null) => {
@@ -198,10 +220,10 @@ export function TreeLibraryPage() {
await treesApi.delete(treeToDelete.id)
setTrees(trees.filter((t) => t.id !== treeToDelete.id))
window.dispatchEvent(new Event('folder-changed'))
toast.success(`Tree "${treeToDelete.name}" deleted successfully`)
toast.success(`"${treeToDelete.name}" deleted successfully`)
} catch (err) {
console.error('Failed to delete tree:', err)
toast.error('Failed to delete tree')
console.error('Failed to delete flow:', err)
toast.error('Failed to delete flow')
} finally {
setIsDeleting(false)
setShowDeleteConfirm(false)
@@ -214,11 +236,11 @@ export function TreeLibraryPage() {
setIsForkingTree(true)
try {
await treesApi.fork(treeId)
toast.success('Tree forked successfully')
toast.success('Flow forked successfully')
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork tree:', err)
toast.error('Failed to fork tree')
console.error('Failed to fork flow:', err)
toast.error('Failed to fork flow')
} finally {
setIsForkingTree(false)
}
@@ -247,21 +269,27 @@ export function TreeLibraryPage() {
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">Decision Trees</h1>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
{typeFilter === 'procedural' ? 'Procedures' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
</h1>
<p className="mt-2 text-white/40">
Select a troubleshooting tree to start a new session
{typeFilter === 'procedural'
? 'Step-by-step procedures for project work'
: typeFilter === 'troubleshooting'
? 'Branching decision flows for troubleshooting'
: 'Browse and start troubleshooting flows and procedures'}
</p>
</div>
{canCreateTrees && (
<Link
to="/trees/new"
to={typeFilter === 'procedural' ? '/flows/new' : '/trees/new'}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90'
)}
>
<Plus className="h-4 w-4" />
Create Tree
{typeFilter === 'procedural' ? 'Create Procedure' : 'Create Flow'}
</Link>
)}
</div>
@@ -284,7 +312,7 @@ export function TreeLibraryPage() {
<div className="flex flex-1 gap-2">
<input
type="text"
placeholder="Search trees..."
placeholder="Search flows..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
@@ -326,6 +354,22 @@ export function TreeLibraryPage() {
{/* View Controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex rounded-lg border border-white/10 p-0.5">
{(['all', 'troubleshooting', 'procedural'] as const).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={cn(
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
typeFilter === t
? 'bg-white/10 text-white'
: 'text-white/40 hover:text-white/60'
)}
>
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Procedures'}
</button>
))}
</div>
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -450,7 +494,7 @@ export function TreeLibraryPage() {
</div>
) : trees.length === 0 ? (
<div className="py-12 text-center text-white/40">
No trees found.{' '}
No flows found.{' '}
{(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'}
</div>
) : (
@@ -525,7 +569,7 @@ export function TreeLibraryPage() {
setTreeToDelete(null)
}}
onConfirm={handleDeleteTree}
title="Delete Tree"
title="Delete Flow"
message={`Are you sure you want to delete "${treeToDelete?.name}"? This action can be undone by an administrator.`}
confirmLabel="Delete"
confirmVariant="destructive"