Files
resolutionflow/frontend/src/pages/ProceduralNavigationPage.tsx
chihlasm 4d2c4930fd feat: Slate & Ice Modern aesthetic redesign (#94)
* chore: update Google Fonts to Bricolage Grotesque, IBM Plex Sans, JetBrains Mono

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update Tailwind config to Slate & Ice theme colors and fonts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update CSS variables and glass-card utilities for Slate & Ice theme

- Replace all color variables with Slate & Ice palette
- Add glass system vars (--glass-bg, --glass-blur, --shadow-float)
- Replace legacy glass-card with new variable-driven glass classes
- Add breatheGlow, bellWobble, slideDown, fadeInRight keyframes
- Update font references to IBM Plex Sans and Bricolage Grotesque

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: recolor BrandLogo to cyan gradient, split BrandWordmark for gradient Flow text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update TopBar with glassmorphism backdrop and cyan accent styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update Sidebar with glassmorphism backdrop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ambient atmosphere gradient orbs behind app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update QuickStats and SessionsPanel with glass-card styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add WeeklyCalendar, QuickActions, OpenSessions, RecentActivity dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign dashboard layout with calendar, open sessions, and glass-card panels

New layout: greeting → calendar+actions → sessions+stats → activity
Replaces old QuickStats and SessionsPanel with new dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace remaining purple hex references with ice-cyan accent

Sweep of hardcoded purple hex values (#818cf8, #6366f1) replaced with
new cyan accent (#06b6d4) in QuickActions, RecentActivity, QuickLaunch,
and SVG brand assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md branding and design system for Slate & Ice Modern

Updated Last Updated date, branding section (fonts, colors, glass
utilities, atmosphere orbs), component styling rules, and Design System
section to reflect the new ice-cyan glassmorphism theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Slate & Ice Modern design doc and implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign login page with Slate & Ice Modern design system

Apply glassmorphism styling, atmosphere orbs, branded wordmark, and
consistent design tokens to match the updated app shell aesthetic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: raise TopBar z-index so profile dropdown renders above main content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI assistant with in-session copilot and standalone chat with RAG

Implements three-phase AI assistant feature:
- Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration,
  tree chunking service, and semantic search over team's flow library
- Phase 1: In-session copilot panel during flow navigation with contextual
  AI help, current step awareness, and suggested related flows
- Phase 2: Standalone AI chat page with persistent conversation history,
  pin/delete, and configurable retention policies (account-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add account management, email verification, AI fixes, and user guides

- Profile settings, account transfer, delete/leave account flows
- Email verification with JWT tokens and Resend integration
- AI assistant/copilot fixes: markdown rendering, shared RAG helpers,
  token tracking, input refocus, model_validate usage
- User guides hub + detail pages with 13 topic guides
- Sidebar and top bar navigation for guides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent stale chunk errors after deployments

- Set Cache-Control no-cache on index.html in nginx so browsers always
  fetch fresh chunk references after a deploy
- Auto-reload on chunk load failures (stale deploy detection) with
  loop prevention via sessionStorage
- Show friendly "App Updated" message if auto-reload doesn't resolve it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add email verification toggle to admin settings

Adds platform-level toggle to enable/disable email verification.
When disabled, the verification banner is hidden and the send
endpoint returns 403.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:44:25 -05:00

729 lines
25 KiB
TypeScript

import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep } from '@/types'
import type { CustomStep } from '@/types/session'
import type { Step } from '@/types/step'
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 { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { StepFeedback } from '@/components/session/StepFeedback'
import { CSATModal } from '@/components/session/CSATModal'
import { hasBeenRated } from '@/components/session/csatUtils'
import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceContextStrip'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
interface StepState {
notes: string
verificationValue: string
completedAt: string | null
}
function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
const result: RuntimeStep[] = [...baseSteps]
const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
for (const cs of sorted) {
const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id)
const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length
const runtimeCustom: CustomProceduralStep = {
id: cs.id,
type: 'procedure_step',
title: cs.step_data.title,
description: cs.step_data.content?.instructions,
content_type: 'action',
commands: cs.step_data.content?.commands?.map((c) => ({
code: c.command,
label: c.label,
})),
isCustom: true,
}
result.splice(insertAt, 0, runtimeCustom)
}
return result
}
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)
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 [showExitConfirm, setShowExitConfirm] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
const [paramsOpen, setParamsOpen] = useState(false)
const [showCsatModal, setShowCsatModal] = useState(false)
const [elapsedMinutes, setElapsedMinutes] = useState(0)
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Custom step state
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
const [showPostStepModal, setShowPostStepModal] = useState(false)
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
const [isSavingStep, setIsSavingStep] = useState(false)
const [copilotOpen, setCopilotOpen] = useState(false)
// 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 = runtimeSteps.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 + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 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])
// Fetch batch progress once when session loads (maintenance flows only)
useEffect(() => {
if (!session?.batch_id) return
sessionsApi.list({ batch_id: session.batch_id, size: 100 })
.then(data => {
if (Array.isArray(data) && data.length > 0) {
const completed = data.filter(s => s.completed_at).length
setBatchProgress({ completed, total: data.length })
}
})
.catch(() => {})
}, [session?.batch_id])
const loadTree = async (id: string) => {
setIsLoading(true)
try {
const treeData = await treesApi.get(id)
if (treeData.tree_type !== 'procedural' && treeData.tree_type !== 'maintenance') {
navigate(`/trees/${id}/navigate`, { replace: true })
return
}
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)
} else {
await startSession(id, {})
}
} catch {
toast.error('Failed to load flow')
navigate('/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)
setRuntimeSteps(allSteps)
setSessionCustomSteps([])
} catch {
toast.error('Failed to start session')
}
}
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)
// Initialize custom steps from session data
const customSteps = sessionData.custom_steps || []
setSessionCustomSteps(customSteps)
const hydrated = buildRuntimeSteps(allSteps, customSteps)
setRuntimeSteps(hydrated)
const initialStates = new Map<string, StepState>()
for (const step of hydrated) {
if (step.type === 'procedure_step') {
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 = hydrated.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('/trees')
}
}
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)
if (!hasBeenRated(session.id)) {
setShowCsatModal(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
})
}
const handleCsatClose = () => {
setShowCsatModal(false)
}
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
setPendingCustomStep(step)
setPendingIsFromLibrary(isFromLibrary)
setShowCustomStepModal(false)
setShowPostStepModal(true)
}
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
if (!session) return
const id = crypto.randomUUID()
const currentStep = procedureSteps[currentStepIndex]
const insertedAfterId = currentStep?.id ?? ''
const runtimeCustom: CustomProceduralStep = {
id,
type: 'procedure_step',
title: step.title,
description: step.content?.instructions,
content_type: 'action',
commands: step.content?.commands?.map((c) => ({
code: c.command,
label: c.label,
})),
isCustom: true,
}
setRuntimeSteps((prev) => {
const next = [...prev]
const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
next.splice(insertAt, 0, runtimeCustom)
return next
})
setStepStates((prev) => {
const next = new Map(prev)
next.set(id, { notes: '', verificationValue: '', completedAt: null })
return next
})
const newCustomStep: CustomStep = {
id,
inserted_after_node_id: insertedAfterId,
step_data: step,
timestamp: new Date().toISOString(),
}
const newCustomSteps = [...sessionCustomSteps, newCustomStep]
setSessionCustomSteps(newCustomSteps)
try {
await sessionsApi.update(session.id, { custom_steps: newCustomSteps })
} catch {
toast.error('Failed to save custom step')
}
setCurrentStepIndex(prev => prev + 1)
}
const handleSaveForLater = async () => {
if (!pendingCustomStep || pendingIsFromLibrary) return
setIsSavingStep(true)
try {
await stepsApi.create({
title: pendingCustomStep.title,
step_type: pendingCustomStep.step_type,
content: pendingCustomStep.content,
visibility: 'private',
})
toast.success('Step saved to library')
} catch {
toast.error('Failed to save step')
} finally {
setIsSavingStep(false)
setShowPostStepModal(false)
setPendingCustomStep(null)
}
}
const handleUseNow = async () => {
if (!pendingCustomStep) return
setShowPostStepModal(false)
await handleInsertCustomStep(pendingCustomStep)
setPendingCustomStep(null)
}
const handleBoth = async () => {
if (!pendingCustomStep || pendingIsFromLibrary) return
setIsSavingStep(true)
try {
await stepsApi.create({
title: pendingCustomStep.title,
step_type: pendingCustomStep.step_type,
content: pendingCustomStep.content,
visibility: 'private',
})
} catch {
toast.error('Failed to save step to library')
} finally {
setIsSavingStep(false)
}
setShowPostStepModal(false)
await handleInsertCustomStep(pendingCustomStep)
setPendingCustomStep(null)
}
// Loading state
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<Spinner />
</div>
)
}
// Intake form modal
if (showIntakeForm && tree) {
return (
<IntakeFormModal
isOpen={true}
fields={tree.intake_form || []}
treeName={tree.name}
onSubmit={handleIntakeSubmit}
onCancel={() => navigate('/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('/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-full min-h-0 flex-col overflow-hidden">
{/* Top bar */}
<div className="border-b border-border 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-muted-foreground hover:bg-accent hover:text-foreground lg:hidden"
>
{sidebarOpen ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
<ListOrdered className="h-5 w-5 text-muted-foreground" />
<h1 className="text-sm font-semibold text-foreground sm:text-base">{tree.name}</h1>
</div>
<button
onClick={() => {
if (currentStepIndex > 0) {
setShowExitConfirm(true)
} else {
navigate('/trees')
}
}}
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<X className="mr-1 inline h-3.5 w-3.5" />
Exit
</button>
</div>
<div className="mt-2">
<ProgressBar
currentStep={completedStepIds.size}
totalSteps={procedureSteps.length}
elapsedMinutes={elapsedMinutes}
estimatedTotalMinutes={estimatedTotalMinutes || undefined}
/>
</div>
</div>
{/* Maintenance context strip */}
{tree?.tree_type === 'maintenance' && session && (
<MaintenanceContextStrip
treeId={treeId!}
targetLabel={session.target_label}
batchId={session.batch_id}
batchProgress={batchProgress}
/>
)}
{/* Main content */}
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Left sidebar - step checklist */}
<div
className={cn(
'min-h-0 border-r border-border bg-card transition-all duration-200',
sidebarOpen ? 'w-72 overflow-y-auto p-3' : 'w-0 overflow-hidden p-0'
)}
>
{sidebarOpen && (
<>
<StepChecklist
steps={runtimeSteps}
currentStepIndex={currentStepIndex}
completedStepIds={completedStepIds}
onStepClick={setCurrentStepIndex}
/>
{/* View Parameters button */}
{Object.keys(sessionVariables).length > 0 && (
<div className="mt-3 border-t border-border pt-3">
<button
onClick={() => setParamsOpen(true)}
className="flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-muted-foreground"
>
<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 overscroll-y-contain 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}
/>
)}
{/* Add custom step — only on current active incomplete non-custom step */}
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
<div className="mt-4">
<button
onClick={() => setShowCustomStepModal(true)}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus className="h-4 w-4" />
Add Step
</button>
</div>
)}
{session && currentStep && !('isCustom' in currentStep && currentStep.isCustom) && (
<div className="mt-3 flex justify-end">
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
</div>
)}
</div>
</div>
{/* CSAT Modal */}
{session && (
<CSATModal
isOpen={showCsatModal}
onClose={handleCsatClose}
sessionId={session.id}
/>
)}
<ConfirmDialog
isOpen={showExitConfirm}
onClose={() => setShowExitConfirm(false)}
onConfirm={() => navigate('/trees')}
title="Exit Session"
message="You have progress in this session. Are you sure you want to exit? Your progress will not be saved."
confirmLabel="Exit"
/>
{/* Parameters popover */}
{paramsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-background/60 backdrop-blur-sm"
onClick={() => setParamsOpen(false)}
/>
<div className="relative w-full max-w-md rounded-2xl border border-border bg-card shadow-2xl backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h3 className="text-sm font-semibold text-foreground">Project Parameters</h3>
<button
onClick={() => setParamsOpen(false)}
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<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-accent px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">{key.replace(/_/g, ' ')}</span>
<span className="text-right text-sm text-muted-foreground">{value || 'N/A'}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Custom Step Modal */}
<CustomStepModal
isOpen={showCustomStepModal}
onClose={() => setShowCustomStepModal(false)}
onInsertStep={handleStepCreated}
/>
{/* Post Step Action Modal */}
{pendingCustomStep && (
<PostStepActionModal
isOpen={showPostStepModal}
onClose={() => { setShowPostStepModal(false); setPendingCustomStep(null) }}
step={pendingCustomStep}
onSaveForLater={handleSaveForLater}
onUseNow={handleUseNow}
onBoth={handleBoth}
isFromLibrary={pendingIsFromLibrary}
isSaving={isSavingStep}
/>
)}
{/* AI Copilot */}
{treeId && (
<>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={runtimeSteps[currentStepIndex]?.id}
/>
</>
)}
</div>
)
}
export default ProceduralNavigationPage