feat: Step Library sync + service account for default tree ownership

* feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85)

- 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 <noreply@anthropic.com>

* feat: session detail page — completion action + outcome summary card

- 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 <noreply@anthropic.com>

* feat: add library-page action props to StepCard (edit/delete/save)

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

* feat: pass library-page action props through StepLibraryBrowser + refreshKey

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

* feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm

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

* feat: Step Library page — create, edit, delete, save-to-library

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

* feat: add RuntimeStep union type for procedural custom steps

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

* feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge

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

* feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps

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

* feat: custom step insertion in procedural flow sessions

Engineers can add custom steps inline during execution. Steps are
persisted to session.custom_steps and restored on resume.

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

* fix: suppress StepFeedback on custom steps, fix resume stepState seeding, functional updater for step index

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

* docs: add tree forking UI design doc

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

* docs: add tree fork UI implementation plan

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

* feat: add ForkInfo type and fork fields to Tree/TreeListItem

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

* fix: align ForkInfo type with backend schema, remove redundant fork fields

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

* fix: ForkInfo placement, required fork_info field, add JSDoc

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

* feat: add ForkModal component with name and reason fields

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

* fix: ForkModal accessibility and UX (escape, click-outside, labels, maxLength)

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

* feat: open ForkModal on fork action in TreeLibraryPage

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

* feat: add ForkModal to MyTreesPage

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

* feat: show Fork chip badge on forked tree cards

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

* docs: add flow-to-library step sync design doc

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

* docs: add flow-to-library sync implementation plan

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

* feat: add sync tracking columns to step_library

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

* feat: add sync columns and source_tree relationship to StepLibrary model

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

* feat: add group_label to StepContent, is_flow_synced/source_tree_name to StepLibraryResponse

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

* feat: include is_flow_synced and source_tree_name in step list/detail responses

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

* fix: add is_flow_synced and source_tree_name to step list response

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

* fix: add selectinload and sync fields to search and get_step endpoints

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

* feat: add step_sync module with extraction and upsert logic

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

* fix: safe NOT IN placeholders for asyncpg, add deactivate docstring

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

* feat: trigger step library sync on tree publish and deactivate on delete

- Call sync_steps_from_tree in update_tree whenever the tree is published
  (status transitions to 'published' or is already published and structure changes)
- Call deactivate_synced_steps_for_tree in delete_tree before db.commit()
  so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs
- Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text()
  queries; replaced with CAST(:content AS jsonb))
- Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model
  so Base.metadata.create_all (used by tests) creates the constraint that the
  ON CONFLICT clause in sync_steps_from_tree depends on

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

* feat: add is_flow_synced and source_tree_name to Step types

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

* feat: show From Flow badge and lock icon on flow-synced StepCard

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

* feat: show source flow name in StepDetailModal for synced steps

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

* feat: add Library Visibility select to procedural StepEditor

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

* fix: address code review issues in flow-to-library sync

- Fix sync trigger: only fire on publish transition, not every PUT
- Add TestSyncOnPublish integration tests (2 tests, 16 total passing)
- Add group_label to frontend StepContent interface
- Guard Library Visibility select to procedure_step nodes only
- Block API edits to flow-synced steps (400 read-only guard)

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

* fix: handle None author_id in step sync to avoid invalid UUID error

When a system/default tree has no author (author_id is None),
str(None) produces the literal string 'None' which asyncpg
rejects as an invalid UUID for the created_by column.

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

* fix: add ResolutionFlow service account to own default tree steps in library

Default/system trees had no author_id (NULL), causing a NOT NULL violation
when syncing steps to step_library.created_by on publish.

- Add is_service_account flag to users table (migration 4f4137ce)
- Add service_account.py: idempotent ensure_service_account() creates
  noreply@resolutionflow.com with unusable password on startup
- Cache service account ID on app.state at lifespan startup
- Add get_service_account_id() FastAPI dep (returns None in tests)
- sync_steps_from_tree: resolve author_id or service_account_id as created_by
- create_tree: set author_id=service_account_id for is_default trees
- Migration 1490781700bc: backfill author_id on 31 existing default trees

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #89.
This commit is contained in:
chihlasm
2026-02-25 23:17:29 -05:00
committed by GitHub
parent a6abd23727
commit e6a0c0549b
45 changed files with 4261 additions and 270 deletions

View File

@@ -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

View File

@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { GitBranch, X } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
interface ForkModalProps {
treeId: string
treeName: string
onClose: () => void
}
export function ForkModal({ treeId, treeName, onClose }: ForkModalProps) {
const navigate = useNavigate()
const [name, setName] = useState(`Copy of ${treeName}`)
const [forkReason, setForkReason] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
setError(null)
try {
await treesApi.fork(treeId, {
name: name.trim(),
fork_reason: forkReason.trim() || undefined,
})
toast.success('Flow forked successfully')
onClose()
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
setError('Failed to fork flow. Please try again.')
} finally {
setIsSubmitting(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Fork Flow</h2>
</div>
<button
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label htmlFor="fork-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Name <span className="text-red-400">*</span>
</label>
<input
id="fork-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
maxLength={255}
className={cn(
'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
<div>
<label htmlFor="fork-reason" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Reason for Forking{' '}
<span className="text-muted-foreground/60">(optional)</span>
</label>
<textarea
id="fork-reason"
value={forkReason}
onChange={(e) => setForkReason(e.target.value)}
rows={3}
placeholder="e.g. customizing for a specific client…"
className={cn(
'w-full resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
{/* Footer */}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Forking…' : 'Fork Flow'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -52,6 +52,11 @@ export function TreeGridView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
</div>
<div className="flex items-center gap-2">
{onTogglePin && (

View File

@@ -53,6 +53,11 @@ export function TreeListView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -182,6 +182,11 @@ export function TreeTableView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -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 (
<div className="flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-amber-400" />
<p className="flex-1 text-[0.875rem] text-amber-300">
Batch in progress · <span className="font-medium">{completed} of {total}</span> targets complete
</p>
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="flex items-center gap-1 text-[0.8125rem] font-medium text-amber-400 hover:text-amber-300 transition-colors"
>
View Batch
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}

View File

@@ -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<string, string> = {
resolved: 'Resolved',
escalated: 'Escalated',
workaround: 'Workaround',
unresolved: 'Unresolved',
}
const OUTCOME_COLORS: Record<string, string> = {
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 (
<div className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 gap-4">
{/* Status indicator + target name */}
<div className="flex items-center gap-3 min-w-0">
{isComplete && (
<CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
)}
{isInProgress && (
<Loader2 className="h-4 w-4 shrink-0 text-amber-400 animate-spin" />
)}
{isNotStarted && (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="text-[0.875rem] font-medium text-foreground truncate">{targetLabel}</p>
<div className="flex items-center gap-2 mt-0.5">
{isComplete && session?.outcome && (
<span className={cn(
"font-label text-[0.625rem] uppercase tracking-wide rounded-full px-2 py-0.5",
OUTCOME_COLORS[session.outcome] ?? OUTCOME_COLORS.unresolved
)}>
{OUTCOME_LABELS[session.outcome] ?? session.outcome}
</span>
)}
{isComplete && session?.started_at && session?.completed_at && (
<span className="text-[0.75rem] text-muted-foreground">
{formatDuration(session.started_at, session.completed_at)}
</span>
)}
{isInProgress && stepProgress && (
<span className="text-[0.75rem] text-amber-400">
Step {stepProgress.completed + 1} of {stepProgress.total}
</span>
)}
{isNotStarted && (
<span className="text-[0.75rem] text-muted-foreground">Not started</span>
)}
</div>
</div>
</div>
{/* Action button */}
<div className="shrink-0">
{isComplete && (
<button
onClick={handleView}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-3.5 w-3.5" />
View
</button>
)}
{isInProgress && (
<button
onClick={handleResume}
className="flex items-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 px-3 py-1.5 text-[0.8125rem] text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Resume
</button>
)}
{isNotStarted && (
<button
onClick={handleStart}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-3 py-1.5 text-[0.8125rem] font-medium text-white shadow-sm shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Play className="h-3.5 w-3.5" />
Start
</button>
)}
</div>
</div>
)
}

View File

@@ -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 (
<div className="flex items-center gap-3 border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm">
<Wrench className="h-3.5 w-3.5 shrink-0 text-amber-400" />
<span className="font-medium text-amber-300">
{targetLabel ? `Target: ${targetLabel}` : 'Manual Run'}
</span>
{batchProgress && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-[0.8125rem]">
{batchProgress.completed} / {batchProgress.total} complete
</span>
</>
)}
{batchId && (
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="ml-auto flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 transition-colors"
>
<ChevronLeft className="h-3 w-3" />
Back to Batch
</button>
)}
</div>
)
}

View File

@@ -254,6 +254,28 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
</label>
</div>
</div>
{/* Library Visibility — procedure_step nodes only */}
{step.type === 'procedure_step' && <div>
<label htmlFor="library-visibility" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Library Visibility
</label>
<select
id="library-visibility"
value={step.library_visibility ?? ''}
onChange={(e) => onUpdate({
library_visibility: e.target.value === '' ? undefined : e.target.value as 'team' | 'public'
})}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="">Inherit from flow</option>
<option value="team">Team only</option>
<option value="public">Public</option>
</select>
<p className="mt-1 text-[10px] text-muted-foreground">
Controls visibility in the step library. Defaults to the flow's own visibility setting.
</p>
</div>}
</div>
)}
</div>

View File

@@ -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<string>
onStepClick: (index: number) => void
@@ -16,7 +16,8 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
const sectionVisibility = new Set<number>()
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
<div key={step.id}>
{showSection && (
<div className="mb-1 mt-3 border-b border-border pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground first:mt-0">
{step.section_header}
{'section_header' in step ? step.section_header : undefined}
</div>
)}
<button
@@ -54,8 +55,15 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-medium">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate">{step.title || 'Untitled step'}</span>
{step.estimated_minutes && (
<span className="min-w-0 flex-1 flex items-center gap-1.5 overflow-hidden">
<span className="truncate">{step.title || 'Untitled step'}</span>
{'isCustom' in step && step.isCustom && (
<span className="shrink-0 rounded-full bg-amber-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-400">
Custom
</span>
)}
</span>
{'estimated_minutes' in step && step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">~{step.estimated_minutes}m</span>
)}
</button>

View File

@@ -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<StepContentType, { icon: typeof Zap; color: stri
}
interface StepDetailProps {
step: ProceduralStep
step: RuntimeStep
stepNumber: number
totalSteps: number
variables: Record<string, string>
@@ -39,13 +39,18 @@ export function StepDetail({
isLast,
}: StepDetailProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(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({
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold text-foreground">{step.title}</h2>
<div className="mt-1 flex items-center gap-2">
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
<Icon className="h-3 w-3" />
{config.label}
</span>
{isCustom ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/15 px-2 py-0.5 text-xs text-amber-400">
Custom Step
</span>
) : (
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
<Icon className="h-3 w-3" />
{config.label}
</span>
)}
<span className="text-xs text-muted-foreground">
Step {stepNumber} of {totalSteps}
</span>
{step.estimated_minutes && (
{'estimated_minutes' in step && step.estimated_minutes && (
<span className="text-xs text-muted-foreground">~{step.estimated_minutes} min</span>
)}
</div>
@@ -102,7 +113,7 @@ export function StepDetail({
</div>
{/* Warning banner */}
{step.warning_text && (
{'warning_text' in step && step.warning_text && (
<div className="flex items-start gap-2 rounded-lg border border-yellow-400/20 bg-yellow-400/5 px-3 py-2.5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-400" />
<p className="text-sm text-yellow-200">{resolve(step.warning_text)}</p>
@@ -142,7 +153,7 @@ export function StepDetail({
)}
{/* Expected outcome */}
{step.expected_outcome && (
{'expected_outcome' in step && step.expected_outcome && (
<div className="rounded-lg border border-border bg-white/[0.02] p-3">
<h4 className="mb-1 text-xs font-medium text-muted-foreground">Expected Outcome</h4>
<p className="text-sm text-muted-foreground">{resolve(step.expected_outcome)}</p>
@@ -181,7 +192,7 @@ export function StepDetail({
)}
{/* Notes */}
{step.notes_enabled !== false && (
{(!('notes_enabled' in step) || step.notes_enabled !== false) && (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Notes</label>
<textarea
@@ -195,7 +206,7 @@ export function StepDetail({
)}
{/* Reference link */}
{step.reference_url && (
{'reference_url' in step && step.reference_url && (
<a
href={resolve(step.reference_url)}
target="_blank"

View File

@@ -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, Lock } 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 (
<div className="group rounded-lg border border-border bg-card p-4 transition-shadow hover:shadow-md">
{/* Header */}
@@ -49,6 +55,13 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
Featured
</span>
)}
{/* From Flow Badge */}
{step.is_flow_synced && (
<span className="rounded-full bg-blue-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-blue-400">
From Flow
</span>
)}
</div>
{/* Title */}
@@ -118,26 +131,89 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onPreview(step)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground transition-colors'
)}
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onInsert(step)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 transition-colors'
)}
>
<Plus className="h-4 w-4" />
Insert
</button>
{(onEdit || onDelete || onSave) ? (
isOwn ? (
step.is_flow_synced ? (
// Flow-synced step: Preview + lock (read-only)
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<span
title="Managed by source flow — fork to customize"
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground opacity-50 cursor-default"
>
<Lock className="h-4 w-4" />
</span>
</>
) : (
// Own step: Preview + Edit + Delete icon
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onEdit?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Pencil className="h-4 w-4" />
Edit
</button>
<button
onClick={() => onDelete?.(step)}
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground hover:bg-red-400/10 hover:text-red-400 hover:border-red-400/30 transition-colors"
aria-label="Delete step"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)
) : (
// Others' step: Preview + Save
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onSave?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
>
<Bookmark className="h-4 w-4" />
Save
</button>
</>
)
) : (
// Session context (original): Preview + Insert
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onInsert?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
>
<Plus className="h-4 w-4" />
Insert
</button>
</>
)}
</div>
</div>
)

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { X, Star, Copy, Check, HelpCircle, Zap, CheckCircle, User, Calendar } from 'lucide-react'
import { X, Star, Copy, Check, HelpCircle, Zap, CheckCircle, User, Calendar, GitBranch } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { stepsApi } from '@/api/steps'
@@ -283,6 +283,12 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
<span className="text-muted-foreground">Author:</span>
<span className="ml-2 font-medium text-foreground">{step.author_name || 'Unknown'}</span>
</div>
{step.is_flow_synced && step.source_tree_name && (
<div className="col-span-2 flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span>Sourced from <span className="font-medium text-foreground">{step.source_tree_name}</span></span>
</div>
)}
<div>
<span className="text-muted-foreground">Usage Count:</span>
<span className="ml-2 font-medium text-foreground">{step.usage_count}</span>

View File

@@ -8,6 +8,8 @@ interface StepFormProps {
onSubmit: (data: StepCreate) => void
onCancel: () => void
initialData?: Partial<StepCreate>
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) {
</button>
<button
type="submit"
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
disabled={isSubmitting}
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
Insert Step
{isSubmitting ? 'Saving...' : (submitLabel ?? 'Insert Step')}
</button>
</div>
</form>

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="relative flex h-[90vh] w-full max-w-2xl flex-col bg-card border border-border rounded-2xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-6 pb-4">
<h2 className="text-lg font-semibold text-foreground">
{isEditMode ? 'Edit Step' : 'Create Step'}
</h2>
<button
onClick={onClose}
disabled={isSubmitting}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Error */}
{error && (
<div className="mx-6 mt-4 rounded-lg border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
<StepForm
onSubmit={handleSubmit}
onCancel={onClose}
initialData={initialData}
submitLabel={isEditMode ? 'Save Changes' : 'Create Step'}
isSubmitting={isSubmitting}
/>
</div>
</div>
</div>
)
}

View File

@@ -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<StepListItem[]>([])
const [categories, setCategories] = useState<StepCategory[]>([])
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -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}
/>
))}
</div>

View File

@@ -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<Tree | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [batchDate, setBatchDate] = useState<Date | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex h-64 items-center justify-center">
<Spinner size="sm" className="h-6 w-6 border-primary border-t-transparent" />
</div>
)
}
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<Record<string, number>>((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 (
<div className="container mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Breadcrumb */}
<div className="flex items-center justify-between">
<button
onClick={() => treeId && navigate(`/flows/${treeId}/maintenance`)}
className="flex items-center gap-1.5 text-[0.875rem] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{tree?.name ?? 'Maintenance Flow'}
</button>
<button
onClick={() => loadSessions(true)}
disabled={isRefreshing}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
Refresh
</button>
</div>
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
<Wrench className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Batch Run</h1>
{batchDate && (
<p className="text-[0.875rem] text-muted-foreground">
{batchDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
</p>
)}
</div>
</div>
{/* Progress bar */}
{total > 0 && (
<div className="rounded-xl border border-border bg-card p-5 space-y-3">
<div className="flex items-center justify-between text-[0.875rem]">
<span className="font-medium text-foreground">
{completed} of {total} complete
</span>
<span className={cn(
'font-label text-[0.6875rem] uppercase tracking-wide rounded-full px-2 py-0.5',
allDone
? 'text-emerald-400 bg-emerald-500/10'
: inProgress > 0
? 'text-amber-400 bg-amber-500/10'
: 'text-muted-foreground bg-muted'
)}>
{allDone ? 'Complete' : inProgress > 0 ? `${inProgress} in progress` : 'Not started'}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
allDone ? 'bg-emerald-500' : 'bg-amber-500'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Completion summary */}
{allDone && Object.keys(outcomeCounts).length > 0 && (
<div className="flex flex-wrap gap-3 pt-1">
{outcomeCounts.resolved && (
<span className="text-[0.8125rem] text-emerald-400">{outcomeCounts.resolved} resolved</span>
)}
{outcomeCounts.escalated && (
<span className="text-[0.8125rem] text-red-400">{outcomeCounts.escalated} escalated</span>
)}
{outcomeCounts.workaround && (
<span className="text-[0.8125rem] text-amber-400">{outcomeCounts.workaround} workaround</span>
)}
{outcomeCounts.unresolved && (
<span className="text-[0.8125rem] text-muted-foreground">{outcomeCounts.unresolved} unresolved</span>
)}
</div>
)}
</div>
)}
{/* Target cards */}
<div className="space-y-2">
{sessions.length === 0 ? (
<p className="text-center text-[0.875rem] text-muted-foreground py-8">
No sessions found for this batch.
</p>
) : (
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) => (
<BatchStatusCard
key={session.id}
session={session}
targetLabel={session.target_label ?? session.id}
treeId={treeId!}
batchId={batchId!}
/>
))
)}
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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<Session[]>([])
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 (
<div className="container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Active batch banner */}
{hasActiveBatch && !bannerDismissed && (
<ActiveBatchBanner
treeId={id!}
sessions={recentSessions}
onDismiss={() => setBannerDismissed(true)}
/>
)}
{/* Header */}
<PageHeader
title={tree.name}
@@ -122,10 +153,13 @@ export default function MaintenanceFlowDetailPage() {
Edit Flow
</button>
<button
onClick={() => navigate(`/flows/${id}/navigate`)}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
onClick={handleRun}
disabled={isRunning}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-70"
>
<Play className="h-3.5 w-3.5" />
{isRunning
? <Spinner size="sm" className="h-3.5 w-3.5 border-white border-t-transparent" />
: <Play className="h-3.5 w-3.5" />}
Run
</button>
<button
@@ -186,29 +220,87 @@ export default function MaintenanceFlowDetailPage() {
<p className="text-[0.875rem] text-muted-foreground">No runs yet. Launch a batch to get started.</p>
) : (
<div className="space-y-2">
{batches.map(([batchKey, sessions]) => {
const completed = sessions.filter(s => s.completed_at).length
const total = sessions.length
const date = sessions[0]?.started_at
{batches.map(([batchKey, batchSessions]) => {
const completed = batchSessions.filter(s => s.completed_at).length
const total = batchSessions.length
const isActive = batchSessions.some(s => s.started_at && !s.completed_at)
const date = batchSessions[0]?.started_at
const isSingleRun = !batchSessions[0]?.batch_id
// Outcome summary
const outcomeCounts = batchSessions.reduce<Record<string, number>>((acc, s) => {
if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1
return acc
}, {})
const outcomeParts = Object.entries(outcomeCounts)
.map(([k, v]) => `${v} ${OUTCOME_LABELS[k] ?? k}`)
// Mini progress dots (up to 8 shown)
const dotsToShow = Math.min(total, 8)
const dots = Array.from({ length: dotsToShow }, (_, i) => i < completed)
const extraDots = total > 8 ? total - 8 : 0
const handleRowClick = () => {
if (isSingleRun && batchSessions[0]) {
navigate(`/sessions/${batchSessions[0].id}`)
} else {
navigate(`/flows/${id}/batches/${batchKey}`)
}
}
return (
<div key={batchKey} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<button
key={batchKey}
onClick={handleRowClick}
className="w-full flex items-center justify-between rounded-lg border border-border px-4 py-3 hover:bg-accent transition-colors text-left"
>
<div>
<p className="text-[0.875rem] font-medium text-foreground">
{total} target{total !== 1 ? 's' : ''}
</p>
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
<div className="flex items-center gap-2">
{isActive && (
<span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
<p className="text-[0.875rem] font-medium text-foreground">
{isSingleRun ? 'Manual run' : `${total} target${total !== 1 ? 's' : ''}`}
</p>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
</p>
)}
{outcomeParts.length > 0 && (
<p className="text-[0.8125rem] text-muted-foreground">
· {outcomeParts.join(' · ')}
</p>
)}
</div>
</div>
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
completed === total ? "text-emerald-400" : "text-amber-400"
)}>
{completed}/{total} complete
</span>
</div>
<div className="flex items-center gap-2">
{!isSingleRun && total > 1 && (
<div className="flex items-center gap-0.5">
{dots.map((done, i) => (
<span
key={i}
className={cn(
'inline-block h-2 w-2 rounded-full',
done ? 'bg-emerald-400' : 'bg-muted'
)}
/>
))}
{extraDots > 0 && (
<span className="ml-1 text-[0.6875rem] text-muted-foreground">+{extraDots}</span>
)}
</div>
)}
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
isActive ? "text-amber-400" : completed === total ? "text-emerald-400" : "text-muted-foreground"
)}>
{isActive ? 'In Progress' : `${completed}/${total}`}
</span>
</div>
</button>
)
})}
</div>

View File

@@ -14,6 +14,7 @@ import { usePermissions } from '@/hooks/usePermissions'
import { toast } from '@/lib/toast'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
import { aiBuilderApi } from '@/api/aiBuilder'
import { ForkModal } from '@/components/library/ForkModal'
interface TreeWithStats extends TreeListItem {
lastUsed?: string
@@ -36,6 +37,7 @@ export function MyTreesPage() {
const [showCreateMenu, setShowCreateMenu] = useState(false)
const [showAIBuilder, setShowAIBuilder] = useState(false)
const [aiEnabled, setAiEnabled] = useState(false)
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
useEffect(() => {
loadMyTrees()
@@ -255,6 +257,11 @@ export function MyTreesPage() {
<Wrench className="h-4 w-4 shrink-0 text-amber-400" />
)}
<h3 className="font-semibold text-foreground">{tree.name}</h3>
{tree.parent_tree_id && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{tree.tree_type === 'procedural' && (
@@ -354,6 +361,14 @@ export function MyTreesPage() {
>
<Share2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setForkTarget(tree)}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title="Fork flow"
>
<GitBranch className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
@@ -401,6 +416,15 @@ export function MyTreesPage() {
/>
)}
{/* Fork Modal */}
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
{/* AI Flow Builder Modal */}
<AIFlowBuilderModal
isOpen={showAIBuilder}

View File

@@ -1,9 +1,12 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types'
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'
@@ -16,6 +19,10 @@ 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'
interface StepState {
notes: string
@@ -23,6 +30,29 @@ interface StepState {
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()
@@ -43,8 +73,18 @@ 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<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)
// Get procedural steps from tree
const getSteps = (): ProceduralStep[] => {
if (!tree) return []
@@ -53,7 +93,7 @@ export function ProceduralNavigationPage() {
}
const steps = getSteps()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
const completedStepIds = new Set(
Array.from(stepStates.entries())
.filter(([, state]) => state.completedAt)
@@ -61,7 +101,7 @@ export function ProceduralNavigationPage() {
)
const estimatedTotalMinutes = procedureSteps.reduce(
(sum, step) => sum + (step.estimated_minutes || 0),
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
0
)
@@ -97,6 +137,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 {
@@ -144,6 +197,8 @@ export function ProceduralNavigationPage() {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
setStepStates(initialStates)
setRuntimeSteps(allSteps)
setSessionCustomSteps([])
} catch {
toast.error('Failed to start session')
}
@@ -158,9 +213,18 @@ export function ProceduralNavigationPage() {
// 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 allSteps) {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
for (const step of hydrated) {
if (step.type === 'procedure_step') {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
}
// Hydrate completed steps from decisions
@@ -176,7 +240,7 @@ export function ProceduralNavigationPage() {
setStepStates(initialStates)
// Set current step to first incomplete step
const pSteps = allSteps.filter((s) => s.type === 'procedure_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 {
@@ -288,6 +352,112 @@ export function ProceduralNavigationPage() {
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 (
@@ -382,6 +552,16 @@ export function ProceduralNavigationPage() {
</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 */}
@@ -394,7 +574,7 @@ export function ProceduralNavigationPage() {
{sidebarOpen && (
<>
<StepChecklist
steps={steps}
steps={runtimeSteps}
currentStepIndex={currentStepIndex}
completedStepIds={completedStepIds}
onStepClick={setCurrentStepIndex}
@@ -433,7 +613,21 @@ export function ProceduralNavigationPage() {
isLast={currentStepIndex === procedureSteps.length - 1}
/>
)}
{session && currentStep && (
{/* 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>
@@ -489,6 +683,27 @@ export function ProceduralNavigationPage() {
</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}
/>
)}
</div>
)
}

View File

@@ -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<Step[]>([])
const [showShareModal, setShowShareModal] = useState(false)
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
const [isCompleting, setIsCompleting] = useState(false)
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(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<string, { icon: React.ReactNode; color: string; bg: string; border: string }> = {
resolved: { icon: <CheckCircle2 className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/20' },
workaround: { icon: <AlertTriangle className="h-5 w-5" />, color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' },
escalated: { icon: <ArrowUpRight className="h-5 w-5" />, color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
unresolved: { icon: <HelpCircle className="h-5 w-5" />, color: 'text-muted-foreground', bg: 'bg-muted', border: 'border-border' },
}
const outcomeConfig = session.outcome ? OUTCOME_CONFIG[session.outcome] : null
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<button
onClick={() => navigate('/sessions')}
className="mb-2 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
<span
className={cn(
'flex items-center gap-1',
session.completed_at ? 'text-emerald-400' : 'text-yellow-400'
{/* Back nav */}
<button
onClick={() => navigate('/sessions')}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
{/* Page title row */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{session.tree_snapshot?.name}
{session.client_name && <> · Client: {session.client_name}</>}
{' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
<ActionMenu
actions={[
{ label: 'Share', icon: Share2, onClick: () => setShowShareModal(true) },
...(session.completed_at ? [{ label: 'Save as Tree', icon: Save, onClick: () => setShowSaveAsTreeModal(true) }] as MenuAction[] : []),
]}
/>
</div>
{/* Session summary card */}
{session.completed_at && outcomeConfig ? (
<div className={cn('mb-6 rounded-xl border p-5', outcomeConfig.border, outcomeConfig.bg)}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<span className={outcomeConfig.color}>{outcomeConfig.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className={cn('text-base font-semibold', outcomeConfig.color)}>{outcomeLabel}</span>
<span className="text-sm text-muted-foreground">· {getTotalDuration()}</span>
</div>
{session.outcome_notes && (
<p className="mt-1 text-sm text-muted-foreground">{session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Next Steps</span>
<p className="mt-0.5 text-sm text-muted-foreground whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
>
<span
className={cn(
'h-2.5 w-2.5 rounded-full',
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
)}
/>
{session.completed_at ? 'Completed' : 'In Progress'}
</span>
{session.client_name && <span>Client: {session.client_name}</span>}
{session.completed_at && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-foreground">
Duration: {getTotalDuration()}
</span>
)}
{outcomeLabel && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-foreground">
Outcome: {outcomeLabel}
</span>
)}
</div>
{session.outcome_notes && (
<p className="mt-2 text-sm text-muted-foreground">Outcome Notes: {session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="text-sm text-muted-foreground">Next Steps:</span>
<p className="mt-0.5 text-sm text-muted-foreground whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<ActionMenu
actions={[
{
label: 'Share',
icon: Share2,
onClick: () => setShowShareModal(true),
},
...(session.completed_at ? [{
label: 'Save as Tree',
icon: Save,
onClick: () => setShowSaveAsTreeModal(true),
}] as MenuAction[] : []),
]}
/>
{/* Copy for Ticket */}
</div>
{/* Primary action: Copy for Ticket */}
<button
onClick={handleCopyForTicket}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90'
)}
className="flex shrink-0 items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
{/* Export Controls */}
<div className="flex items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
aria-label="Export format"
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground sm:w-auto',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className={cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>
Through step {idx + 1}
</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className={cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className={cn(
'rounded-md border border-border bg-card p-2 text-muted-foreground',
'hover:bg-accent hover:text-foreground disabled:opacity-50'
)}
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<button
onClick={handlePreview}
disabled={isExporting}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90 disabled:opacity-50'
)}
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</button>
</div>
</div>
</div>
) : !session.completed_at ? (
/* In-progress banner */
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4">
<div className="flex items-center gap-3">
<Flag className="h-4 w-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">Session in progress</p>
<p className="text-xs text-muted-foreground">Set an outcome to finalize this session and generate documentation.</p>
</div>
</div>
<button
onClick={() => setShowOutcomeModal(true)}
className="shrink-0 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
Complete Session
</button>
</div>
) : null}
{/* Export toolbar (secondary) */}
<div className="mb-6 flex flex-wrap items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
aria-label="Export format"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>Through step {idx + 1}</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className="rounded-md border border-border bg-card p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<button
onClick={handlePreview}
disabled={isExporting}
className="flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</button>
{/* Copy for ticket (secondary position when session is complete) */}
{session.completed_at && (
<button
onClick={handleCopyForTicket}
className="flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
{copiedPsa ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
)}
</div>
{/* Timeline / Step Checklist */}
@@ -513,6 +530,14 @@ export function SessionDetailPage() {
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
/>
{/* Complete Session Modal (in-progress sessions) */}
<SessionOutcomeModal
isOpen={showOutcomeModal}
onClose={() => setShowOutcomeModal(false)}
onSubmit={handleCompleteSession}
isSubmitting={isCompleting}
/>
</div>
)
}

View File

@@ -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<Step | null>(null)
// Delete confirmation state
const [deletingStep, setDeletingStep] = useState<StepListItem | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
// Toast for "Save to My Library"
const [saveToast, setSaveToast] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<span title="Step Library"><Bookmark className="h-8 w-8 text-muted-foreground" /></span>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Step Library</h1>
<span title="Step Library">
<Bookmark className="h-6 w-6 text-muted-foreground" />
</span>
<div>
<h1 className="text-xl font-bold font-heading text-foreground">Step Library</h1>
<p className="text-sm text-muted-foreground">Reusable steps you can insert into any flow</p>
</div>
</div>
<p className="mt-2 text-muted-foreground">Reusable steps for your flows coming soon.</p>
{canCreateSteps && (
<button
onClick={() => setCreateOpen(true)}
className="rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
+ Create Step
</button>
)}
</div>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Bookmark className="h-8 w-8 text-primary" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">Coming Soon</h2>
<p className="max-w-md text-sm text-muted-foreground">
The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows.
</p>
{/* Browser fills remaining height */}
<div className="flex-1 overflow-hidden">
<StepLibraryBrowser
onEdit={(step) => { handleEdit(step) }}
onDelete={handleDeleteRequest}
onSave={handleSave}
currentUserId={user?.id}
refreshKey={refreshKey}
showCreateButton={false}
/>
</div>
{/* Create / Edit Modal */}
<StepFormModal
isOpen={createOpen || !!editingStep}
onClose={handleCloseModal}
onSuccess={handleFormSuccess}
editingStep={editingStep}
/>
{/* Delete Confirmation Dialog */}
{deletingStep && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-xl bg-card border border-border p-6 shadow-lg">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-red-400/10 p-2">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<h2 className="text-base font-semibold text-foreground">Delete Step</h2>
</div>
<p className="mb-2 text-sm text-muted-foreground">
Are you sure you want to delete{' '}
<span className="font-medium text-foreground">"{deletingStep.title}"</span>?
</p>
<p className="mb-6 text-xs text-muted-foreground">This cannot be undone.</p>
{deleteError && (
<p className="mb-4 text-sm text-red-400">{deleteError}</p>
)}
<div className="flex gap-2">
<button
onClick={() => { setDeletingStep(null); setDeleteError(null) }}
disabled={isDeleting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Save Toast */}
{saveToast && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-foreground shadow-lg">
{saveToast}
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { foldersApi } from '@/api/folders'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
import { FolderEditModal } from '@/components/library/FolderEditModal'
import { ForkModal } from '@/components/library/ForkModal'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { TreeGridView } from '@/components/library/TreeGridView'
import { TreeListView } from '@/components/library/TreeListView'
@@ -72,8 +73,8 @@ export function TreeLibraryPage() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Fork state
const [isForkingTree, setIsForkingTree] = useState(false)
// Fork modal state
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
// AI builder state
const [showAIBuilder, setShowAIBuilder] = useState(false)
@@ -244,19 +245,9 @@ export function TreeLibraryPage() {
}
}
const handleForkTree = async (treeId: string) => {
if (isForkingTree) return
setIsForkingTree(true)
try {
await treesApi.fork(treeId)
toast.success('Flow forked successfully')
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
toast.error('Failed to fork flow')
} finally {
setIsForkingTree(false)
}
const handleForkTree = (treeId: string) => {
const tree = trees.find((t) => t.id === treeId)
if (tree) setForkTarget(tree)
}
const hasActiveFilters =
@@ -572,6 +563,14 @@ export function TreeLibraryPage() {
onClose={() => setShowAIBuilder(false)}
/>
)}
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
</div>
)
}

View File

@@ -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([
</Suspense>
),
},
{
path: 'flows/:id/batches/:batchId',
element: (
<Suspense fallback={<PageLoader />}>
<BatchStatusPage />
</Suspense>
),
},
{
path: 'trees/:id/navigate',
element: (

View File

@@ -10,6 +10,7 @@ export interface StepContent {
instructions: string
help_text?: string
commands?: StepCommand[]
group_label?: string
}
export interface Step {
@@ -28,6 +29,8 @@ export interface Step {
helpful_no: number
is_featured: boolean
is_verified: boolean
is_flow_synced: boolean
source_tree_name: string | null
created_by: string
author_name?: string
created_at: string
@@ -46,6 +49,8 @@ export interface StepListItem {
rating_average: number
rating_count: number
is_featured: boolean
is_flow_synced: boolean
source_tree_name: string | null
created_by: string
author_name?: string
created_at: string

View File

@@ -121,8 +121,21 @@ export interface ProceduralStep {
notes_enabled?: boolean
section_header?: string
reference_url?: string
library_visibility?: 'team' | 'public'
}
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[]
}
@@ -130,6 +143,16 @@ export interface ProceduralTreeStructure {
// API response types
export type TreeStatus = 'draft' | 'published'
/** Fork lineage metadata. Only present (non-null) on trees that are forks (fork_depth > 0). */
export interface ForkInfo {
parent_tree_id: string | null
root_tree_id: string | null
fork_reason: string | null
fork_depth: number
parent_updated_at: string | null
has_parent_updates: boolean
}
export interface Tree {
id: string
name: string
@@ -152,6 +175,7 @@ export interface Tree {
created_at: string
updated_at: string
usage_count: number
fork_info: ForkInfo | null
}
export interface TreeListItem {