Files
resolutionflow/docs/plans/2026-02-24-procedural-custom-steps-plan.md
2026-02-26 08:09:12 -05:00

20 KiB

Procedural Custom Steps — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add "Add Custom Step" support to ProceduralNavigationPage so engineers can insert ad-hoc steps between existing checklist items during a session.

Architecture: Introduce a RuntimeStep = ProceduralStep | CustomProceduralStep discriminated union type. ProceduralNavigationPage grows new state + handlers for the custom-step modal flow (reusing CustomStepModal and PostStepActionModal as-is). StepChecklist and StepDetail accept RuntimeStep instead of ProceduralStep. No backend changes needed.

Tech Stack: React 19, TypeScript, Tailwind CSS, existing sessionsApi.update, CustomStepModal, PostStepActionModal


Task 1: Add RuntimeStep union type

Files:

  • Modify: frontend/src/types/tree.ts
  • Modify: frontend/src/types/index.ts (re-export if needed)

Context: ProceduralStep is already in tree.ts along with CommandBlock. The new CustomProceduralStep interface lives in the same file. No hook needed — this is purely a type.

Step 1: Add the types at the bottom of frontend/src/types/tree.ts

After the existing ProceduralStep interface (line ~124), add:

export interface CustomProceduralStep {
  id: string
  type: 'procedure_step'
  title: string
  description?: string
  content_type: 'action'
  commands?: CommandBlock[]
  isCustom: true
}

export type RuntimeStep = ProceduralStep | CustomProceduralStep

Step 2: Export from frontend/src/types/index.ts

Check what is currently re-exported from tree.ts in index.ts and add CustomProceduralStep and RuntimeStep to the export list.

Run: cd frontend && npm run build 2>&1 | tail -20 Expected: no new type errors

Step 3: Commit

git add frontend/src/types/tree.ts frontend/src/types/index.ts
git commit -m "feat: add RuntimeStep union type for procedural custom steps

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

Task 2: Update StepChecklist to accept RuntimeStep[]

Files:

  • Modify: frontend/src/components/procedural/StepChecklist.tsx

Context: Currently StepChecklistProps.steps is ProceduralStep[]. It filters internally for type === 'procedure_step'. Custom steps already have type: 'procedure_step' so the filter still works. We need to:

  1. Change prop type to RuntimeStep[]
  2. Render an amber "Custom" badge next to custom step titles

Step 1: Update the import and prop type

Change:

import type { ProceduralStep } from '@/types'

To:

import type { RuntimeStep } from '@/types'

Change StepChecklistProps:

interface StepChecklistProps {
  steps: RuntimeStep[]
  currentStepIndex: number
  completedStepIds: Set<string>
  onStepClick: (index: number) => void
}

Step 2: Add the "Custom" badge in the step row

In the button's <span className="min-w-0 flex-1 truncate"> row, add a badge after the title when 'isCustom' in step && step.isCustom:

<span className="min-w-0 flex-1 flex items-center gap-1.5 truncate">
  <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>

Step 3: Build check

Run: cd frontend && npm run build 2>&1 | tail -20 Expected: no new errors

Step 4: Commit

git add frontend/src/components/procedural/StepChecklist.tsx
git commit -m "feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge

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

Task 3: Update StepDetail to accept RuntimeStep

Files:

  • Modify: frontend/src/components/procedural/StepDetail.tsx

Context: StepDetail currently only accepts ProceduralStep. For CustomProceduralStep, we skip the content_type badge (show "Custom Step" label instead), hide warning_text/expected_outcome/verification/reference_url sections (they don't exist on custom steps), and show description + commands normally. The canComplete() check and Mark Complete button stay the same.

Step 1: Update import and prop type

import type { RuntimeStep, CommandBlock } from '@/types'

Change StepDetailProps:

interface StepDetailProps {
  step: RuntimeStep
  // ... rest unchanged
}

Step 2: Handle custom step header (replace content_type badge block)

The existing header block uses step.content_type to pick a config. Add a guard:

// At top of component body, after existing state:
const isCustom = 'isCustom' in step && step.isCustom

// In the header JSX, replace the content_type badge 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>
)}

Note: contentType and config are still computed but only used in the non-custom branch. TypeScript will be happy because ProceduralStep has content_type and custom step skips that branch.

Step 3: Guard sections that don't exist on custom steps

Sections that need guarding (wrap with !isCustom &&):

  • {step.warning_text && ...} — already implicitly guarded since CustomProceduralStep has no warning_text, but TypeScript may complain → add !isCustom && before the expression
  • {step.expected_outcome && ...} — same
  • {verificationPrompt && ...} — same (derive verificationPrompt with !isCustom && check)
  • {step.reference_url && ...} — same

The description block, commandBlocks block, and notes block all work fine as-is (custom steps have description? and commands? matching the normalized shapes).

Specifically, for verificationPrompt / verificationType:

const verificationPrompt = isCustom ? undefined : (step.verification_prompt || step.verification?.prompt)
const verificationType = isCustom ? undefined : (step.verification_type || step.verification?.type)

For commandBlocks normalization — CustomProceduralStep.commands is CommandBlock[] already, so the existing ternary handles it fine.

Step 4: Build check

Run: cd frontend && npm run build 2>&1 | tail -20 Expected: no errors

Step 5: Commit

git add frontend/src/components/procedural/StepDetail.tsx
git commit -m "feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps

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

Task 4: Wire custom step flow into ProceduralNavigationPage

Files:

  • Modify: frontend/src/pages/ProceduralNavigationPage.tsx

Context: This is the main task. Current page has no custom step support. We add:

  • runtimeSteps: RuntimeStep[] state (replaces procedureSteps derived value)
  • sessionCustomSteps: CustomStep[] state
  • showCustomStepModal, showPostStepModal, pendingCustomStep, pendingIsFromLibrary, isSavingStep state
  • handleStepCreated — closes CustomStepModal, opens PostStepActionModal
  • handleInsertCustomStep — builds CustomProceduralStep, inserts into runtimeSteps, persists
  • handleSaveForLater / handleUseNow / handleBoth — wire PostStepActionModal buttons
  • "Add Step" button below StepDetail, above StepFeedback
  • Resume: inject custom steps at correct positions

Step 1: Add new imports

import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
import type { RuntimeStep, CustomProceduralStep } from '@/types'
import type { CustomStep } from '@/types/session'
import type { Step } from '@/types/step'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { Plus } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'

Check if uuid package is already available: grep -r "from 'uuid'" frontend/src/. If not, use crypto.randomUUID() instead (available in all modern browsers and Node 16+).

Step 2: Add new state (after existing state declarations)

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)

Step 3: Replace procedureSteps derivation

Currently:

const procedureSteps = steps.filter((s) => s.type === 'procedure_step')

Replace with:

// runtimeSteps is the authoritative list; filter for rendering (excludes section_headers)
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')

Also update estimatedTotalMinutes — it only sums estimated_minutes which only exists on ProceduralStep. Cast safely:

const estimatedTotalMinutes = procedureSteps.reduce(
  (sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
  0
)

Step 4: Initialize runtimeSteps in startSession

After setSession(newSession), add:

// Initialize runtimeSteps from tree steps
const allSteps = getStepsFromTree(tree!)
setRuntimeSteps(allSteps)
setSessionCustomSteps([])

The existing step state initialization loop stays the same — custom steps will add to it when inserted.

Step 5: Initialize runtimeSteps in resumeSession

After loading sessionData, before computing pSteps:

// Build runtimeSteps: start with tree steps, then inject custom steps
const allSteps = getStepsFromTree(treeData)
const customSteps = sessionData.custom_steps || []
setSessionCustomSteps(customSteps)

// Inject custom steps at correct positions
const hydrated = buildRuntimeSteps(allSteps, customSteps)
setRuntimeSteps(hydrated)

And update the pSteps / firstIncomplete calculation to use hydrated (not allSteps):

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)

Step 6: Add buildRuntimeSteps helper

Add this function before the component (or as a module-level utility):

function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
  const result: RuntimeStep[] = [...baseSteps]
  // Sort custom steps by timestamp so earlier insertions come first if multiple
  const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
  for (const cs of sorted) {
    // Find the index of the step this was inserted after
    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
}

Note: ProceduralStep needs to be imported in scope here. Since it's used in getStepsFromTree already, it's available.

Step 7: Add handleStepCreated

const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
  setPendingCustomStep(step)
  setPendingIsFromLibrary(isFromLibrary)
  setShowCustomStepModal(false)
  setShowPostStepModal(true)
}

Step 8: Add handleInsertCustomStep

const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
  if (!session) return

  const id = crypto.randomUUID()
  const currentStep = procedureSteps[currentStepIndex]
  const insertedAfterId = currentStep?.id ?? ''

  // Build the runtime representation
  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,
  }

  // Insert after currentStepIndex in runtimeSteps
  setRuntimeSteps((prev) => {
    const next = [...prev]
    // Find the global index of the current procedureStep in runtimeSteps
    const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
    const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
    next.splice(insertAt, 0, runtimeCustom)
    return next
  })

  // Initialize step state for the new step
  setStepStates((prev) => {
    const next = new Map(prev)
    next.set(id, { notes: '', verificationValue: '', completedAt: null })
    return next
  })

  // Persist to session
  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')
  }

  // Advance to the new step (it's now at currentStepIndex + 1)
  setCurrentStepIndex(currentStepIndex + 1)
}

Step 9: Add handleSaveForLater, handleUseNow, handleBoth

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)
}

Add stepsApi import at the top: import { stepsApi } from '@/api/steps'

Step 10: Update handleMarkComplete to use runtimeSteps-based procedureSteps

The existing handleMarkComplete reads procedureSteps[currentStepIndex] — since procedureSteps is now derived from runtimeSteps, this Just Works. But the completion check currentStepIndex >= procedureSteps.length - 1 also works correctly.

No changes needed to handleMarkComplete itself.

Step 11: Update StepChecklist call in JSX

Change steps={steps} to steps={runtimeSteps}:

<StepChecklist
  steps={runtimeSteps}
  currentStepIndex={currentStepIndex}
  completedStepIds={completedStepIds}
  onStepClick={setCurrentStepIndex}
/>

Step 12: Update StepDetail call + add "Add Step" button

Change step={currentStep} prop type is now RuntimeStep (TypeScript satisfied since procedureSteps is RuntimeStep[]).

Change totalSteps={procedureSteps.length} — still correct.

After the StepDetail closing tag and before StepFeedback, add the "Add Step" button:

{/* Add Custom Step button — only on current active (incomplete) step, not on custom steps */}
{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>
)}

Step 13: Add modals at the bottom of the JSX (before closing </div>)

After the ConfirmDialog and parameters popover, add:

{/* 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}
  />
)}

Step 14: ProgressBar total count update

The ProgressBar already uses procedureSteps.length as totalSteps which is derived from runtimeSteps — so the progress bar total automatically updates when custom steps are inserted.

Step 15: Build check

Run: cd frontend && npm run build 2>&1 | tail -30 Expected: no errors. Watch especially for:

  • CustomStep import (it's in @/types/session, not @/types — import directly if needed)
  • buildRuntimeSteps needing ProceduralStep type explicitly imported

Step 16: Commit

git add frontend/src/pages/ProceduralNavigationPage.tsx
git commit -m "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>"

Task 5: Manual verification checklist

No code changes — verification only.

Start a procedural session (or maintenance session). Verify:

  1. Add Step button appears below the current step detail on an active, incomplete, non-custom step
  2. Add Step button is hidden on completed steps
  3. Clicking Add Step opens CustomStepModal with Create + Browse Library tabs
  4. Creating a stepPostStepActionModal appears
  5. "Use Now" → closes modal, custom step appears as next step in checklist with amber "Custom" badge, current view advances to it
  6. "Save for Later" → closes modal, nothing inserted, no crash
  7. "Do Both" → saves to library AND inserts
  8. Mark Complete on custom step → advances to next step normally
  9. Custom step in StepDetail → shows "Custom Step" amber badge instead of content_type badge
  10. Progress bar total increments when custom step is inserted
  11. Resume session (navigate away and back with { state: { sessionId } }) → custom step reappears at correct position in checklist

Task 6: Final build validation

Run: cd frontend && npm run build Expected: build succeeds with zero errors (pre-existing chunk-size warnings are fine).

Commit build validation result if no issues:

git add -p  # stage any incidental fixes
git commit -m "chore: final build validation for procedural custom steps

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

Only commit if there were actual changes to stage.