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 */}
+ {/* 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: (