feat: integrate AI panel, context menu, and ghost steps in procedural editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-06 23:43:10 -05:00
parent 040da262b3
commit 68714fad3c
2 changed files with 149 additions and 47 deletions

View File

@@ -39,7 +39,11 @@ function SortableStepWrapper({
)
}
export function StepList() {
interface StepListProps {
onStepContextMenu?: (e: React.MouseEvent, stepId: string) => void
}
export function StepList({ onStepContextMenu }: StepListProps) {
const {
steps,
intakeForm,
@@ -208,6 +212,7 @@ export function StepList() {
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
const isGhost = !!(step as unknown as Record<string, unknown>)._suggestion
if (isExpanded) {
return (
@@ -232,53 +237,80 @@ export function StepList() {
{({ dragHandleProps }) => (
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50'
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50',
isGhost && 'border-l-2 border-dashed !border-l-primary/40 opacity-60'
)}
onContextMenu={(e) => onStepContextMenu?.(e, step.id)}
>
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{isGhost && (
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2">
<button
onClick={(e) => {
e.stopPropagation()
// TODO: accept suggestion
}}
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
>
Accept
</button>
<button
onClick={(e) => {
e.stopPropagation()
// TODO: dismiss suggestion
}}
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
>
Dismiss
</button>
</div>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</SortableStepWrapper>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar } from 'lucide-react'
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar, Sparkles, Layers } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
@@ -10,6 +10,10 @@ import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils
import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { Spinner } from '@/components/common/Spinner'
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
import { ContextMenu } from '@/components/common/ContextMenu'
import { useEditorAI } from '@/hooks/useEditorAI'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
@@ -44,6 +48,13 @@ export function ProceduralEditorPage() {
getTreeForSave,
} = useProceduralEditorStore()
const steps = useProceduralEditorStore(s => s.steps)
const editorAI = useEditorAI({
flowType: 'procedural',
treeId: id,
})
const isMaintenance = treeType === 'maintenance'
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
@@ -175,6 +186,19 @@ export function ProceduralEditorPage() {
{isDirty && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
<button
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
title="Toggle AI Assist panel"
className={cn(
'flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
editorAI.isOpen
? 'bg-primary/10 text-primary border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<Sparkles className="h-4 w-4" />
AI Assist
</button>
<button
onClick={() => handleSave('draft')}
disabled={isSaving}
@@ -272,10 +296,56 @@ export function ProceduralEditorPage() {
)}
</div>
{/* Step List — flex-1, scrolls independently */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<StepList />
{/* Step List + AI Panel */}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 py-4">
<StepList onStepContextMenu={editorAI.openContextMenu} />
</div>
<EditorAIPanel
isOpen={editorAI.isOpen}
onClose={editorAI.closePanel}
focalNode={null}
flowName={name}
flowType={isMaintenance ? 'maintenance' : 'procedural'}
nodeCount={steps.length}
messages={editorAI.messages}
input={editorAI.input}
onInputChange={editorAI.setInput}
onSend={editorAI.sendMessage}
isLoading={editorAI.isLoading}
suggestions={editorAI.suggestions}
/>
</div>
{editorAI.contextMenu && (
<ContextMenu
position={editorAI.contextMenu.position}
items={[
{
id: 'generate-steps',
label: 'Generate Steps After',
icon: <Sparkles className="h-4 w-4" />,
onClick: () => editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'add_steps',
`Generate steps after this step`
),
},
{
id: 'expand-step',
label: 'Expand Step',
icon: <Layers className="h-4 w-4" />,
onClick: () => editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'quick_action',
`Expand this step into detailed substeps`
),
},
]}
onClose={editorAI.closeContextMenu}
/>
)}
</div>
)
}