From 8db34f07ee7059b403628b8aaa6839e84146a520 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 18:51:42 -0500 Subject: [PATCH 01/39] =?UTF-8?q?feat:=20maintenance=20flow=20UX=20redesig?= =?UTF-8?q?n=20=E2=80=94=20batch=20status=20hub,=20context=20strip,=20deta?= =?UTF-8?q?il=20page=20upgrades=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary - Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets - Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage - Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav - Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage - Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount - Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type - Add /flows/:id/batches/:batchId route to router Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 + backend/app/api/endpoints/sessions.py | 5 + frontend/src/api/sessions.ts | 1 + .../maintenance/ActiveBatchBanner.tsx | 46 ++++ .../maintenance/BatchStatusCard.tsx | 144 ++++++++++++ .../maintenance/MaintenanceContextStrip.tsx | 46 ++++ frontend/src/pages/BatchStatusPage.tsx | 209 ++++++++++++++++++ .../src/pages/MaintenanceFlowDetailPage.tsx | 146 +++++++++--- .../src/pages/ProceduralNavigationPage.tsx | 25 +++ frontend/src/router.tsx | 9 + 10 files changed, 608 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/maintenance/ActiveBatchBanner.tsx create mode 100644 frontend/src/components/maintenance/BatchStatusCard.tsx create mode 100644 frontend/src/components/maintenance/MaintenanceContextStrip.tsx create mode 100644 frontend/src/pages/BatchStatusPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d19f2bfa..fc937870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,6 +275,10 @@ navigate(`/trees/${newTree.id}/edit`) **25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`. +**26. `sessionsApi.list` supports `batch_id` filter (added Feb 2026):** Both backend `GET /sessions` and frontend `SessionListParams` accept `batch_id` for querying all sessions in a maintenance batch. Use `sessionsApi.list({ batch_id })` to fetch batch-scoped sessions. + +**27. Maintenance batch sessions are created all-at-once at launch:** All sessions in a batch exist immediately after `batchLaunchApi.launch()` with `batch_id` + `target_label` set. `started_at` is null until a user begins executing that target — there is no "pending session creation" state. + --- ## RBAC & Permissions diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 12b3f67e..5e7b9c5f 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -38,6 +38,7 @@ async def list_sessions( client_name: Optional[str] = Query(None, description="Search by client name (partial match)"), tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"), tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"), + batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"), started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"), started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"), completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"), @@ -73,6 +74,10 @@ async def list_sessions( if tree_id: query = query.where(Session.tree_id == tree_id) + # Batch ID filter + if batch_id: + query = query.where(Session.batch_id == batch_id) + # Date range filters if started_after: query = query.where(Session.started_at >= started_after) diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index ffda6080..69f6605f 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -5,6 +5,7 @@ export interface SessionListParams { page?: number size?: number tree_id?: string + batch_id?: string completed?: boolean ticket_number?: string client_name?: string diff --git a/frontend/src/components/maintenance/ActiveBatchBanner.tsx b/frontend/src/components/maintenance/ActiveBatchBanner.tsx new file mode 100644 index 00000000..8ea28da1 --- /dev/null +++ b/frontend/src/components/maintenance/ActiveBatchBanner.tsx @@ -0,0 +1,46 @@ +import { X, RefreshCw, ChevronRight } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import type { Session } from '@/types' + +interface ActiveBatchBannerProps { + treeId: string + sessions: Session[] + onDismiss: () => void +} + +export function ActiveBatchBanner({ treeId, sessions, onDismiss }: ActiveBatchBannerProps) { + const navigate = useNavigate() + + // Find the most recently started in-progress batch + const inProgressSessions = sessions.filter(s => s.started_at && !s.completed_at && s.batch_id) + if (inProgressSessions.length === 0) return null + + const batchId = inProgressSessions[0].batch_id! + + // Count all sessions in this batch + const batchSessions = sessions.filter(s => s.batch_id === batchId) + const completed = batchSessions.filter(s => s.completed_at).length + const total = batchSessions.length + + return ( +
+ +

+ Batch in progress · {completed} of {total} targets complete +

+ + +
+ ) +} diff --git a/frontend/src/components/maintenance/BatchStatusCard.tsx b/frontend/src/components/maintenance/BatchStatusCard.tsx new file mode 100644 index 00000000..62ce004e --- /dev/null +++ b/frontend/src/components/maintenance/BatchStatusCard.tsx @@ -0,0 +1,144 @@ +import { CheckCircle, Circle, Loader2, Play, RotateCcw, Eye } from 'lucide-react' +import { useNavigate } from 'react-router-dom' +import { cn } from '@/lib/utils' +import type { Session } from '@/types' + +interface BatchStatusCardProps { + session: Session | null + targetLabel: string + treeId: string + batchId: string +} + +function formatDuration(startedAt: string, completedAt: string): string { + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime() + const totalSeconds = Math.floor(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + if (minutes === 0) return `${seconds}s` + return `${minutes}m ${seconds}s` +} + +const OUTCOME_LABELS: Record = { + resolved: 'Resolved', + escalated: 'Escalated', + workaround: 'Workaround', + unresolved: 'Unresolved', +} + +const OUTCOME_COLORS: Record = { + resolved: 'text-emerald-400 bg-emerald-500/10', + escalated: 'text-red-400 bg-red-500/10', + workaround: 'text-amber-400 bg-amber-500/10', + unresolved: 'text-muted-foreground bg-muted', +} + +export function BatchStatusCard({ session, targetLabel, treeId, batchId }: BatchStatusCardProps) { + const navigate = useNavigate() + + const isComplete = !!session?.completed_at + const isInProgress = !!session?.started_at && !session.completed_at + const isNotStarted = !session || !session.started_at + + // Derive step progress for in-progress sessions + const stepProgress = (() => { + if (!session || !isInProgress) return null + const snapshot = session.tree_snapshot as { steps?: { type: string }[] } | null + const totalSteps = snapshot?.steps?.filter(s => s.type === 'procedure_step').length ?? 0 + const completedSteps = (session.decisions ?? []).filter(d => d.answer === 'completed').length + return totalSteps > 0 ? { completed: completedSteps, total: totalSteps } : null + })() + + const handleStart = () => { + navigate(`/flows/${treeId}/navigate`, { + state: { targetLabel, batchId }, + }) + } + + const handleResume = () => { + if (!session) return + navigate(`/flows/${treeId}/navigate`, { + state: { sessionId: session.id }, + }) + } + + const handleView = () => { + if (!session) return + navigate(`/sessions/${session.id}`) + } + + return ( +
+ {/* Status indicator + target name */} +
+ {isComplete && ( + + )} + {isInProgress && ( + + )} + {isNotStarted && ( + + )} + +
+

{targetLabel}

+
+ {isComplete && session?.outcome && ( + + {OUTCOME_LABELS[session.outcome] ?? session.outcome} + + )} + {isComplete && session?.started_at && session?.completed_at && ( + + {formatDuration(session.started_at, session.completed_at)} + + )} + {isInProgress && stepProgress && ( + + Step {stepProgress.completed + 1} of {stepProgress.total} + + )} + {isNotStarted && ( + Not started + )} +
+
+
+ + {/* Action button */} +
+ {isComplete && ( + + )} + {isInProgress && ( + + )} + {isNotStarted && ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/maintenance/MaintenanceContextStrip.tsx b/frontend/src/components/maintenance/MaintenanceContextStrip.tsx new file mode 100644 index 00000000..a1039976 --- /dev/null +++ b/frontend/src/components/maintenance/MaintenanceContextStrip.tsx @@ -0,0 +1,46 @@ +import { Wrench, ChevronLeft } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +interface MaintenanceContextStripProps { + treeId: string + targetLabel?: string | null + batchId?: string | null + batchProgress?: { completed: number; total: number } | null +} + +export function MaintenanceContextStrip({ + treeId, + targetLabel, + batchId, + batchProgress, +}: MaintenanceContextStripProps) { + const navigate = useNavigate() + + return ( +
+ + + {targetLabel ? `Target: ${targetLabel}` : 'Manual Run'} + + + {batchProgress && ( + <> + · + + {batchProgress.completed} / {batchProgress.total} complete + + + )} + + {batchId && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/BatchStatusPage.tsx b/frontend/src/pages/BatchStatusPage.tsx new file mode 100644 index 00000000..c6a0de1e --- /dev/null +++ b/frontend/src/pages/BatchStatusPage.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { ChevronLeft, RefreshCw, Wrench } from 'lucide-react' +import { treesApi } from '@/api/trees' +import { sessionsApi } from '@/api/sessions' +import { BatchStatusCard } from '@/components/maintenance/BatchStatusCard' +import { Spinner } from '@/components/common/Spinner' +import { cn } from '@/lib/utils' +import type { Tree, Session } from '@/types' + +// Batch sessions are created with a shared batch_id and individual target_labels. +// Some targets may not have a session yet if created via a pre-populated target list +// where sessions were created all at once — in practice all sessions exist at batch +// launch time, so we group by target_label from the sessions we get back. + +export default function BatchStatusPage() { + const { id: treeId, batchId } = useParams<{ id: string; batchId: string }>() + const navigate = useNavigate() + + const [tree, setTree] = useState(null) + const [sessions, setSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isRefreshing, setIsRefreshing] = useState(false) + const [batchDate, setBatchDate] = useState(null) + const pollRef = useRef | null>(null) + + const loadSessions = useCallback(async (showRefreshing = false) => { + if (!batchId) return + if (showRefreshing) setIsRefreshing(true) + try { + const data = await sessionsApi.list({ batch_id: batchId, size: 100 }) + setSessions(Array.isArray(data) ? data : []) + if (data.length > 0 && data[0].started_at) { + setBatchDate(new Date(data[0].started_at)) + } + } finally { + if (showRefreshing) setIsRefreshing(false) + } + }, [batchId]) + + // Initial load + useEffect(() => { + if (!treeId || !batchId) return + const load = async () => { + try { + const [treeData] = await Promise.all([ + treesApi.get(treeId), + loadSessions(), + ]) + setTree(treeData) + } finally { + setIsLoading(false) + } + } + load() + }, [treeId, batchId, loadSessions]) + + // Polling: refresh every 5s while any session is in-progress + useEffect(() => { + const hasInProgress = sessions.some(s => s.started_at && !s.completed_at) + if (hasInProgress) { + pollRef.current = setInterval(() => loadSessions(), 5000) + } else { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [sessions, loadSessions]) + + if (isLoading) { + return ( +
+ +
+ ) + } + + const total = sessions.length + const completed = sessions.filter(s => s.completed_at).length + const inProgress = sessions.filter(s => s.started_at && !s.completed_at).length + const allDone = total > 0 && completed === total + + // Outcome summary for completion + const outcomeCounts = sessions.reduce>((acc, s) => { + if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1 + return acc + }, {}) + + const progressPercent = total > 0 ? Math.round((completed / total) * 100) : 0 + + return ( +
+ {/* Breadcrumb */} +
+ + +
+ + {/* Header */} +
+
+ +
+
+

Batch Run

+ {batchDate && ( +

+ {batchDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })} +

+ )} +
+
+ + {/* Progress bar */} + {total > 0 && ( +
+
+ + {completed} of {total} complete + + 0 + ? 'text-amber-400 bg-amber-500/10' + : 'text-muted-foreground bg-muted' + )}> + {allDone ? 'Complete' : inProgress > 0 ? `${inProgress} in progress` : 'Not started'} + +
+
+
+
+ + {/* Completion summary */} + {allDone && Object.keys(outcomeCounts).length > 0 && ( +
+ {outcomeCounts.resolved && ( + {outcomeCounts.resolved} resolved + )} + {outcomeCounts.escalated && ( + {outcomeCounts.escalated} escalated + )} + {outcomeCounts.workaround && ( + {outcomeCounts.workaround} workaround + )} + {outcomeCounts.unresolved && ( + {outcomeCounts.unresolved} unresolved + )} +
+ )} +
+ )} + + {/* Target cards */} +
+ {sessions.length === 0 ? ( +

+ No sessions found for this batch. +

+ ) : ( + sessions + .sort((a, b) => { + // Sort: in-progress first, then not-started, then complete + const rank = (s: Session) => { + if (s.started_at && !s.completed_at) return 0 + if (!s.started_at) return 1 + return 2 + } + return rank(a) - rank(b) + }) + .map((session) => ( + + )) + )} +
+
+ ) +} diff --git a/frontend/src/pages/MaintenanceFlowDetailPage.tsx b/frontend/src/pages/MaintenanceFlowDetailPage.tsx index 1b5c7303..d6c66080 100644 --- a/frontend/src/pages/MaintenanceFlowDetailPage.tsx +++ b/frontend/src/pages/MaintenanceFlowDetailPage.tsx @@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal' +import { ActiveBatchBanner } from '@/components/maintenance/ActiveBatchBanner' import { Spinner } from '@/components/common/Spinner' import { EmptyState } from '@/components/common/EmptyState' import { PageHeader } from '@/components/common/PageHeader' @@ -12,6 +13,13 @@ import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' import type { Tree, MaintenanceSchedule, Session } from '@/types' +const OUTCOME_LABELS: Record = { + resolved: 'resolved', + escalated: 'escalated', + workaround: 'workaround', + unresolved: 'unresolved', +} + export default function MaintenanceFlowDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -20,6 +28,8 @@ export default function MaintenanceFlowDetailPage() { const [recentSessions, setRecentSessions] = useState([]) const [showBatchModal, setShowBatchModal] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isRunning, setIsRunning] = useState(false) + const [bannerDismissed, setBannerDismissed] = useState(false) useEffect(() => { if (!id) return @@ -33,7 +43,6 @@ export default function MaintenanceFlowDetailPage() { } setTree(treeData) - // Load recent sessions for this tree try { const sessionData = await sessionsApi.list({ tree_id: id, size: 30 }) setRecentSessions(Array.isArray(sessionData) ? sessionData : []) @@ -41,7 +50,6 @@ export default function MaintenanceFlowDetailPage() { // Sessions load is optional } - // Try to load schedule (404 is fine) try { const sched = await maintenanceSchedulesApi.getForTree(id) setSchedule(sched) @@ -58,10 +66,21 @@ export default function MaintenanceFlowDetailPage() { load() }, [id, navigate]) - const handleLaunched = (_batchId: string, count: number) => { + const handleLaunched = (batchId: string, _count: number) => { setShowBatchModal(false) - toast.success(`${count} sessions created — view them in Sessions`) - navigate('/sessions') + setBannerDismissed(false) + // Reload sessions so banner picks up the new batch + if (id) { + sessionsApi.list({ tree_id: id, size: 30 }) + .then(data => setRecentSessions(Array.isArray(data) ? data : [])) + .catch(() => {}) + } + navigate(`/flows/${id}/batches/${batchId}`) + } + + const handleRun = () => { + setIsRunning(true) + navigate(`/flows/${id}/navigate`) } if (isLoading) { @@ -100,8 +119,20 @@ export default function MaintenanceFlowDetailPage() { } const batches = Array.from(batchMap.entries()).slice(0, 10) + // Show banner only if there are in-progress batch sessions and it hasn't been dismissed + const hasActiveBatch = recentSessions.some(s => s.started_at && !s.completed_at && s.batch_id) + return (
+ {/* Active batch banner */} + {hasActiveBatch && !bannerDismissed && ( + setBannerDismissed(true)} + /> + )} + {/* Header */}
+
+ {!isSingleRun && total > 1 && ( +
+ {dots.map((done, i) => ( + + ))} + {extraDots > 0 && ( + +{extraDots} + )} +
+ )} + + {isActive ? 'In Progress' : `${completed}/${total}`} + +
+ ) })}
diff --git a/frontend/src/pages/ProceduralNavigationPage.tsx b/frontend/src/pages/ProceduralNavigationPage.tsx index 8f416d2a..81500dae 100644 --- a/frontend/src/pages/ProceduralNavigationPage.tsx +++ b/frontend/src/pages/ProceduralNavigationPage.tsx @@ -16,6 +16,7 @@ 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' interface StepState { notes: string @@ -43,6 +44,7 @@ export function ProceduralNavigationPage() { 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 | null>(null) // Get procedural steps from tree @@ -97,6 +99,19 @@ export function ProceduralNavigationPage() { } }, [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 { @@ -382,6 +397,16 @@ export function ProceduralNavigationPage() { + {/* Maintenance context strip */} + {tree?.tree_type === 'maintenance' && session && ( + + )} + {/* Main content */}
{/* Left sidebar - step checklist */} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 18f99a8e..3d497ea3 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -25,6 +25,7 @@ const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage')) const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage')) const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage')) const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage')) +const BatchStatusPage = lazy(() => import('@/pages/BatchStatusPage')) const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage')) const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage')) const MySharesPage = lazy(() => import('@/pages/MySharesPage')) @@ -180,6 +181,14 @@ export const router = createBrowserRouter([ ), }, + { + path: 'flows/:id/batches/:batchId', + element: ( + }> + + + ), + }, { path: 'trees/:id/navigate', element: ( -- 2.49.1 From 516218739e394c5ddd1226e2d65e3b096130df60 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 19:39:09 -0500 Subject: [PATCH 02/39] =?UTF-8?q?feat:=20session=20detail=20page=20?= =?UTF-8?q?=E2=80=94=20completion=20action=20+=20outcome=20summary=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In-progress sessions: amber banner with "Complete Session" button opens SessionOutcomeModal to set outcome/notes/next-steps and finalize - Completed sessions: colored outcome summary card (icon + outcome label + duration + notes + next steps) replaces dense header metadata; "Copy for Ticket" promoted to primary action inside the card - Export toolbar de-emphasized to secondary row of smaller controls below the summary card Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/SessionDetailPage.tsx | 309 ++++++++++++----------- 1 file changed, 167 insertions(+), 142 deletions(-) diff --git a/frontend/src/pages/SessionDetailPage.tsx b/frontend/src/pages/SessionDetailPage.tsx index 2dd5739b..00adc888 100644 --- a/frontend/src/pages/SessionDetailPage.tsx +++ b/frontend/src/pages/SessionDetailPage.tsx @@ -1,15 +1,17 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' -import { Copy, Check, Eye, Save, Share2 } from 'lucide-react' +import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag } from 'lucide-react' import { sessionsApi } from '@/api/sessions' import { stepsApi } from '@/api/steps' import { ExportPreviewModal } from '@/components/session/ExportPreviewModal' import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal' import { ShareSessionModal } from '@/components/session/ShareSessionModal' +import { SessionOutcomeModal } from '@/components/session/SessionOutcomeModal' import { SessionTimeline } from '@/components/session/SessionTimeline' import { StepRatingModal } from '@/components/session/StepRatingModal' import { ActionMenu } from '@/components/common/ActionMenu' import type { MenuAction } from '@/components/common/ActionMenu' +import type { SessionOutcome } from '@/types' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' @@ -36,6 +38,8 @@ export function SessionDetailPage() { const [isSavingRatings, setIsSavingRatings] = useState(false) const [librarySteps, setLibrarySteps] = useState([]) const [showShareModal, setShowShareModal] = useState(false) + const [showOutcomeModal, setShowOutcomeModal] = useState(false) + const [isCompleting, setIsCompleting] = useState(false) const [maxStepIndex, setMaxStepIndex] = useState(null) const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard') const [includeSummary, setIncludeSummary] = useState(false) @@ -227,6 +231,21 @@ export function SessionDetailPage() { } } + const handleCompleteSession = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => { + if (!session) return + setIsCompleting(true) + try { + const updated = await sessionsApi.complete(session.id, data) + setSession(updated) + setShowOutcomeModal(false) + toast.success('Session completed') + } catch { + toast.error('Failed to complete session') + } finally { + setIsCompleting(false) + } + } + const getDefaultTreeName = () => { if (!session) return '' const treeName = session.tree_snapshot?.name || 'Tree' @@ -310,159 +329,157 @@ export function SessionDetailPage() { ) } + // Outcome display config + const OUTCOME_CONFIG: Record = { + resolved: { icon: , color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/20' }, + workaround: { icon: , color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' }, + escalated: { icon: , color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' }, + unresolved: { icon: , color: 'text-muted-foreground', bg: 'bg-muted', border: 'border-border' }, + } + const outcomeConfig = session.outcome ? OUTCOME_CONFIG[session.outcome] : null + return (
- {/* Header */} -
-
-
- -

- {session.ticket_number || 'Session Details'} -

-
- navigate('/sessions')} + className="mb-4 text-sm text-muted-foreground hover:text-foreground" + > + ← Back to sessions + + + {/* Page title row */} +
+
+

+ {session.ticket_number || 'Session Details'} +

+

+ {session.tree_snapshot?.name} + {session.client_name && <> · Client: {session.client_name}} + {' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })} +

+
+ setShowShareModal(true) }, + ...(session.completed_at ? [{ label: 'Save as Tree', icon: Save, onClick: () => setShowSaveAsTreeModal(true) }] as MenuAction[] : []), + ]} + /> +
+ + {/* Session summary card */} + {session.completed_at && outcomeConfig ? ( +
+
+
+ {outcomeConfig.icon} +
+
+ {outcomeLabel} + · {getTotalDuration()} +
+ {session.outcome_notes && ( +

{session.outcome_notes}

+ )} + {session.next_steps && ( +
+ Next Steps +

{session.next_steps}

+
)} - > - - {session.completed_at ? 'Completed' : 'In Progress'} - - {session.client_name && Client: {session.client_name}} - {session.completed_at && ( - - Duration: {getTotalDuration()} - - )} - {outcomeLabel && ( - - Outcome: {outcomeLabel} - - )} -
- {session.outcome_notes && ( -

Outcome Notes: {session.outcome_notes}

- )} - {session.next_steps && ( -
- Next Steps: -

{session.next_steps}

- )} -
- - {/* Actions */} -
- setShowShareModal(true), - }, - ...(session.completed_at ? [{ - label: 'Save as Tree', - icon: Save, - onClick: () => setShowSaveAsTreeModal(true), - }] as MenuAction[] : []), - ]} - /> - - {/* Copy for Ticket */} +
+ {/* Primary action: Copy for Ticket */} - - {/* Export Controls */} -
- - {session.decisions.length > 1 && ( - - )} - - - -
+ ) : !session.completed_at ? ( + /* In-progress banner */ +
+
+ +
+

Session in progress

+

Set an outcome to finalize this session and generate documentation.

+
+
+ +
+ ) : null} + + {/* Export toolbar (secondary) */} +
+ + {session.decisions.length > 1 && ( + + )} + + + + {/* Copy for ticket (secondary position when session is complete) */} + {session.completed_at && ( + + )}
{/* Timeline / Step Checklist */} @@ -513,6 +530,14 @@ export function SessionDetailPage() { isOpen={showShareModal} onClose={() => setShowShareModal(false)} /> + + {/* Complete Session Modal (in-progress sessions) */} + setShowOutcomeModal(false)} + onSubmit={handleCompleteSession} + isSubmitting={isCompleting} + />
) } -- 2.49.1 From 0fbe50c38ba9bf73d4802c41d52537cdbeae7dc8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 20:38:49 -0500 Subject: [PATCH 03/39] feat: add library-page action props to StepCard (edit/delete/save) Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/step-library/StepCard.tsx | 96 ++++++++++++++----- 1 file changed, 73 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/step-library/StepCard.tsx b/frontend/src/components/step-library/StepCard.tsx index 5a37e672..a4be0efc 100644 --- a/frontend/src/components/step-library/StepCard.tsx +++ b/frontend/src/components/step-library/StepCard.tsx @@ -1,11 +1,15 @@ -import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle } from 'lucide-react' +import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle, Pencil, Trash2, Bookmark } from 'lucide-react' import { cn } from '@/lib/utils' import type { StepListItem } from '@/types/step' interface StepCardProps { step: StepListItem onPreview: (step: StepListItem) => void - onInsert: (step: StepListItem) => void + onInsert?: (step: StepListItem) => void // session context (now optional) + onEdit?: (step: StepListItem) => void // library page + onDelete?: (step: StepListItem) => void // library page — NOTE: pass full StepListItem, not just ID + onSave?: (step: StepListItem) => void // library page (save copy to My Steps) + currentUserId?: string // to determine ownership } const stepTypeIcons = { @@ -20,12 +24,14 @@ const stepTypeColors = { solution: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20' } -export function StepCard({ step, onPreview, onInsert }: StepCardProps) { +export function StepCard({ step, onPreview, onInsert, onEdit, onDelete, onSave, currentUserId }: StepCardProps) { const Icon = stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] || HelpCircle const hasRating = step.rating_count > 0 const visibleTags = step.tags.slice(0, 3) const remainingTags = step.tags.length - 3 + const isOwn = currentUserId ? step.created_by === currentUserId : false + return (
{/* Header */} @@ -118,26 +124,70 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) { {/* Actions */}
- - + {(onEdit || onDelete || onSave) ? ( + isOwn ? ( + // Own step: Preview + Edit + Delete icon + <> + + + + + ) : ( + // Others' step: Preview + Save + <> + + + + ) + ) : ( + // Session context (original): Preview + Insert + <> + + + + )}
) -- 2.49.1 From f1c5898b9f077dec8ad20c3c80fd30b31f61c1fc Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 20:41:55 -0500 Subject: [PATCH 04/39] feat: pass library-page action props through StepLibraryBrowser + refreshKey Co-Authored-By: Claude Sonnet 4.6 --- .../step-library/StepLibraryBrowser.tsx | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/step-library/StepLibraryBrowser.tsx b/frontend/src/components/step-library/StepLibraryBrowser.tsx index 1b610f31..9cd07c7c 100644 --- a/frontend/src/components/step-library/StepLibraryBrowser.tsx +++ b/frontend/src/components/step-library/StepLibraryBrowser.tsx @@ -8,12 +8,17 @@ import { StepDetailModal } from './StepDetailModal' import type { Step, StepListItem, StepCategory, PopularTag, StepListParams } from '@/types/step' interface StepLibraryBrowserProps { - onInsert: (step: Step) => void + onInsert?: (step: Step) => void onCreateNew?: () => void showCreateButton?: boolean + onEdit?: (step: StepListItem) => void + onDelete?: (step: StepListItem) => void + onSave?: (step: StepListItem) => void + currentUserId?: string + refreshKey?: number } -export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = false }: StepLibraryBrowserProps) { +export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = false, onEdit, onDelete, onSave, currentUserId, refreshKey }: StepLibraryBrowserProps) { // State const [steps, setSteps] = useState([]) const [categories, setCategories] = useState([]) @@ -87,7 +92,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f } loadSteps() - }, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag]) + }, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey]) // Group steps by visibility const groupedSteps = useMemo(() => { @@ -108,12 +113,15 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f const handleInsertFromPreview = (step: Step) => { setPreviewStepId(null) - onInsert(step) + if (onInsert) { + onInsert(step) + } } const handleInsertFromCard = (stepItem: StepListItem) => { - // Need to fetch full step details for insert - stepsApi.get(stepItem.id).then(onInsert) + if (onInsert) { + stepsApi.get(stepItem.id).then(onInsert) + } } const handleTagClick = (tag: string) => { @@ -275,7 +283,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f key={step.id} step={step} onPreview={handlePreview} - onInsert={handleInsertFromCard} + onInsert={onInsert ? handleInsertFromCard : undefined} + onEdit={onEdit} + onDelete={onDelete} + onSave={onSave} + currentUserId={currentUserId} /> ))}
@@ -304,7 +316,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f key={step.id} step={step} onPreview={handlePreview} - onInsert={handleInsertFromCard} + onInsert={onInsert ? handleInsertFromCard : undefined} + onEdit={onEdit} + onDelete={onDelete} + onSave={onSave} + currentUserId={currentUserId} /> ))}
@@ -333,7 +349,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f key={step.id} step={step} onPreview={handlePreview} - onInsert={handleInsertFromCard} + onInsert={onInsert ? handleInsertFromCard : undefined} + onEdit={onEdit} + onDelete={onDelete} + onSave={onSave} + currentUserId={currentUserId} /> ))}
-- 2.49.1 From 8f46e0e227514886c1ceb79f04cb76d1ff45fcdc Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 20:43:40 -0500 Subject: [PATCH 05/39] feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/step-library/StepForm.tsx | 9 +- .../components/step-library/StepFormModal.tsx | 89 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/step-library/StepFormModal.tsx diff --git a/frontend/src/components/step-library/StepForm.tsx b/frontend/src/components/step-library/StepForm.tsx index 59a4052a..11902da2 100644 --- a/frontend/src/components/step-library/StepForm.tsx +++ b/frontend/src/components/step-library/StepForm.tsx @@ -8,6 +8,8 @@ interface StepFormProps { onSubmit: (data: StepCreate) => void onCancel: () => void initialData?: Partial + submitLabel?: string + isSubmitting?: boolean } const stepTypeOptions = [ @@ -16,7 +18,7 @@ const stepTypeOptions = [ { value: 'solution', label: 'Solution', icon: CheckCircle, description: 'Resolution endpoint' } ] as const -export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) { +export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmitting }: StepFormProps) { // Form state const [stepType, setStepType] = useState<'decision' | 'action' | 'solution'>( initialData?.step_type || 'action' @@ -376,9 +378,10 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
diff --git a/frontend/src/components/step-library/StepFormModal.tsx b/frontend/src/components/step-library/StepFormModal.tsx new file mode 100644 index 00000000..58a7a95c --- /dev/null +++ b/frontend/src/components/step-library/StepFormModal.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { stepsApi } from '@/api/steps' +import { StepForm } from './StepForm' +import type { Step, StepCreate } from '@/types/step' + +interface StepFormModalProps { + isOpen: boolean + onClose: () => void + onSuccess: (step: Step) => void + editingStep?: Step | null // full Step (parent fetches before opening), null/undefined = create mode +} + +export function StepFormModal({ isOpen, onClose, onSuccess, editingStep }: StepFormModalProps) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + if (!isOpen) return null + + const isEditMode = !!editingStep + + const handleSubmit = async (data: StepCreate) => { + setIsSubmitting(true) + setError(null) + try { + let result: Step + if (isEditMode && editingStep) { + result = await stepsApi.update(editingStep.id, data) + } else { + result = await stepsApi.create(data) + } + onSuccess(result) + } catch (err) { + console.error('Failed to save step:', err) + setError('Failed to save step. Please try again.') + } finally { + setIsSubmitting(false) + } + } + + // Build initialData from full Step including content + const initialData = editingStep ? { + title: editingStep.title, + step_type: editingStep.step_type, + content: editingStep.content, + visibility: editingStep.visibility, + category_id: editingStep.category_id, + tags: editingStep.tags, + } : undefined + + return ( +
+
+ {/* Header */} +
+

+ {isEditMode ? 'Edit Step' : 'Create Step'} +

+ +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Body */} +
+ +
+
+
+ ) +} -- 2.49.1 From 3f6e126f97705983bddcb181a1ccaba79e603b1d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 20:46:17 -0500 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20Step=20Library=20page=20=E2=80=94?= =?UTF-8?q?=20create,=20edit,=20delete,=20save-to-library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/StepLibraryPage.tsx | 182 +++++++++++++++++++++++-- 1 file changed, 168 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/StepLibraryPage.tsx b/frontend/src/pages/StepLibraryPage.tsx index 5592c7d8..532acd4d 100644 --- a/frontend/src/pages/StepLibraryPage.tsx +++ b/frontend/src/pages/StepLibraryPage.tsx @@ -1,25 +1,179 @@ -import { Bookmark } from 'lucide-react' +import { useState } from 'react' +import { Bookmark, Trash2 } from 'lucide-react' +import { useAuthStore } from '@/store/authStore' +import { usePermissions } from '@/hooks/usePermissions' +import { stepsApi } from '@/api/steps' +import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser' +import { StepFormModal } from '@/components/step-library/StepFormModal' +import type { Step, StepListItem } from '@/types/step' export default function StepLibraryPage() { + const user = useAuthStore((s) => s.user) + const { canCreateSteps } = usePermissions() + + // Create/edit modal state + const [createOpen, setCreateOpen] = useState(false) + const [editingStep, setEditingStep] = useState(null) + + // Delete confirmation state + const [deletingStep, setDeletingStep] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) + + // Toast for "Save to My Library" + const [saveToast, setSaveToast] = useState(null) + + // Increment to trigger StepLibraryBrowser reload + const [refreshKey, setRefreshKey] = useState(0) + const refresh = () => setRefreshKey(k => k + 1) + + // Fetch full step before opening edit modal (StepListItem lacks content) + const handleEdit = async (step: StepListItem) => { + try { + const full = await stepsApi.get(step.id) + setEditingStep(full) + } catch (err) { + console.error('Failed to load step for edit:', err) + } + } + + const handleDeleteRequest = (step: StepListItem) => { + setDeletingStep(step) + } + + const handleDeleteConfirm = async () => { + if (!deletingStep) return + setIsDeleting(true) + setDeleteError(null) + try { + await stepsApi.delete(deletingStep.id) + setDeletingStep(null) + refresh() + } catch (err) { + console.error('Failed to delete step:', err) + setDeleteError('Failed to delete step. Please try again.') + } finally { + setIsDeleting(false) + } + } + + const handleSave = async (step: StepListItem) => { + try { + const full = await stepsApi.get(step.id) + await stepsApi.create({ + title: full.title, + step_type: full.step_type, + content: full.content, + visibility: 'private', + category_id: full.category_id, + tags: full.tags, + }) + setSaveToast(`"${full.title}" saved to My Steps`) + setTimeout(() => setSaveToast(null), 3000) + refresh() + } catch (err) { + console.error('Failed to save step:', err) + } + } + + const handleFormSuccess = (_step: Step) => { + setCreateOpen(false) + setEditingStep(null) + refresh() + } + + const handleCloseModal = () => { + setCreateOpen(false) + setEditingStep(null) + } + return ( -
-
+
+ {/* Page Header */} +
- -

Step Library

+ + + +
+

Step Library

+

Reusable steps you can insert into any flow

+
-

Reusable steps for your flows — coming soon.

+ {canCreateSteps && ( + + )}
-
-
- -
-

Coming Soon

-

- The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows. -

+ {/* Browser fills remaining height */} +
+ { handleEdit(step) }} + onDelete={handleDeleteRequest} + onSave={handleSave} + currentUserId={user?.id} + refreshKey={refreshKey} + showCreateButton={false} + />
+ + {/* Create / Edit Modal */} + + + {/* Delete Confirmation Dialog */} + {deletingStep && ( +
+
+
+
+ +
+

Delete Step

+
+

+ Are you sure you want to delete{' '} + "{deletingStep.title}"? +

+

This cannot be undone.

+ {deleteError && ( +

{deleteError}

+ )} +
+ + +
+
+
+ )} + + {/* Save Toast */} + {saveToast && ( +
+ {saveToast} +
+ )}
) } -- 2.49.1 From bb8d2fbcb26f2c078ad5c37d338f3bd9d026092a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 21:43:17 -0500 Subject: [PATCH 07/39] feat: add RuntimeStep union type for procedural custom steps Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/types/tree.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index 916c43e8..c4595b1a 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -123,6 +123,18 @@ export interface ProceduralStep { reference_url?: string } +export interface CustomProceduralStep { + id: string + type: 'procedure_step' + title: string + description?: string + content_type: 'action' + commands?: CommandBlock[] + isCustom: true +} + +export type RuntimeStep = ProceduralStep | CustomProceduralStep + export interface ProceduralTreeStructure { steps: ProceduralStep[] } -- 2.49.1 From e691c978f1b52fefeb191b5c141a29dfce9432e4 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 21:46:55 -0500 Subject: [PATCH 08/39] feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge Co-Authored-By: Claude Sonnet 4.6 --- .../components/procedural/StepChecklist.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/procedural/StepChecklist.tsx b/frontend/src/components/procedural/StepChecklist.tsx index 5823eff9..3bdd4942 100644 --- a/frontend/src/components/procedural/StepChecklist.tsx +++ b/frontend/src/components/procedural/StepChecklist.tsx @@ -1,9 +1,9 @@ import { CheckCircle2, Circle, ArrowRight } from 'lucide-react' -import type { ProceduralStep } from '@/types' +import type { RuntimeStep } from '@/types' import { cn } from '@/lib/utils' interface StepChecklistProps { - steps: ProceduralStep[] + steps: RuntimeStep[] currentStepIndex: number completedStepIds: Set onStepClick: (index: number) => void @@ -16,7 +16,8 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte const sectionVisibility = new Set() let prevSection: string | undefined for (let i = 0; i < procedureSteps.length; i++) { - const header = procedureSteps[i].section_header + const s = procedureSteps[i] + const header = 'section_header' in s ? s.section_header : undefined if (header && header !== prevSection) sectionVisibility.add(i) if (header) prevSection = header } @@ -32,7 +33,7 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
{showSection && (
- {step.section_header} + {'section_header' in step ? step.section_header : undefined}
)} -- 2.49.1 From c2dbabc61999205a8f456727a4336169c2572e82 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 24 Feb 2026 21:49:15 -0500 Subject: [PATCH 09/39] feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/procedural/StepDetail.tsx | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/procedural/StepDetail.tsx b/frontend/src/components/procedural/StepDetail.tsx index 1e5616b5..1af4b6c2 100644 --- a/frontend/src/components/procedural/StepDetail.tsx +++ b/frontend/src/components/procedural/StepDetail.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { AlertTriangle, CheckCircle2, Info, Zap, Copy, Check, ExternalLink } from 'lucide-react' -import type { ProceduralStep, StepContentType, CommandBlock } from '@/types' +import type { RuntimeStep, StepContentType, CommandBlock } from '@/types' import { resolveVariables } from '@/lib/variableResolver' import { cn } from '@/lib/utils' @@ -12,7 +12,7 @@ const contentTypeConfig: Record @@ -39,13 +39,18 @@ export function StepDetail({ isLast, }: StepDetailProps) { const [copiedIndex, setCopiedIndex] = useState(null) + const isCustom = 'isCustom' in step && step.isCustom const contentType = step.content_type || 'action' const config = contentTypeConfig[contentType] const Icon = config.icon // Derive verification from either flat fields or nested object - const verificationPrompt = step.verification_prompt || step.verification?.prompt - const verificationType = step.verification_type || step.verification?.type + const verificationPrompt = !isCustom && 'verification_prompt' in step + ? step.verification_prompt || step.verification?.prompt + : undefined + const verificationType = !isCustom && 'verification_type' in step + ? step.verification_type || step.verification?.type + : undefined const resolve = (text: string | undefined) => { if (!text) return '' @@ -87,14 +92,20 @@ export function StepDetail({

{step.title}

- - - {config.label} - + {isCustom ? ( + + ✦ Custom Step + + ) : ( + + + {config.label} + + )} Step {stepNumber} of {totalSteps} - {step.estimated_minutes && ( + {'estimated_minutes' in step && step.estimated_minutes && ( ~{step.estimated_minutes} min )}
@@ -102,7 +113,7 @@ export function StepDetail({
{/* Warning banner */} - {step.warning_text && ( + {'warning_text' in step && step.warning_text && (

{resolve(step.warning_text)}

@@ -142,7 +153,7 @@ export function StepDetail({ )} {/* Expected outcome */} - {step.expected_outcome && ( + {'expected_outcome' in step && step.expected_outcome && (

Expected Outcome

{resolve(step.expected_outcome)}

@@ -181,7 +192,7 @@ export function StepDetail({ )} {/* Notes */} - {step.notes_enabled !== false && ( + {(!('notes_enabled' in step) || step.notes_enabled !== false) && (