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:
@@ -39,7 +39,11 @@ function SortableStepWrapper({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepList() {
|
interface StepListProps {
|
||||||
|
onStepContextMenu?: (e: React.MouseEvent, stepId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepList({ onStepContextMenu }: StepListProps) {
|
||||||
const {
|
const {
|
||||||
steps,
|
steps,
|
||||||
intakeForm,
|
intakeForm,
|
||||||
@@ -208,6 +212,7 @@ export function StepList() {
|
|||||||
const contentType = step.content_type || 'action'
|
const contentType = step.content_type || 'action'
|
||||||
const config = contentTypeConfig[contentType]
|
const config = contentTypeConfig[contentType]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
|
const isGhost = !!(step as unknown as Record<string, unknown>)._suggestion
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
@@ -232,53 +237,80 @@ export function StepList() {
|
|||||||
{({ dragHandleProps }) => (
|
{({ dragHandleProps }) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
|
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||||
'hover:border-primary/30 hover:bg-accent/50'
|
'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
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
type="button"
|
||||||
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
|
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||||
{...dragHandleProps}
|
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
|
||||||
>
|
{...dragHandleProps}
|
||||||
<GripVertical className="h-4 w-4" />
|
>
|
||||||
</button>
|
<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">
|
<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}
|
{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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SortableStepWrapper>
|
</SortableStepWrapper>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
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 { treesApi } from '@/api/trees'
|
||||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||||
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
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 { StepList } from '@/components/procedural-editor/StepList'
|
||||||
import { TagInput } from '@/components/common/TagInput'
|
import { TagInput } from '@/components/common/TagInput'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
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 { toast } from '@/lib/toast'
|
||||||
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
|
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
|
||||||
|
|
||||||
@@ -44,6 +48,13 @@ export function ProceduralEditorPage() {
|
|||||||
getTreeForSave,
|
getTreeForSave,
|
||||||
} = useProceduralEditorStore()
|
} = useProceduralEditorStore()
|
||||||
|
|
||||||
|
const steps = useProceduralEditorStore(s => s.steps)
|
||||||
|
|
||||||
|
const editorAI = useEditorAI({
|
||||||
|
flowType: 'procedural',
|
||||||
|
treeId: id,
|
||||||
|
})
|
||||||
|
|
||||||
const isMaintenance = treeType === 'maintenance'
|
const isMaintenance = treeType === 'maintenance'
|
||||||
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
||||||
|
|
||||||
@@ -175,6 +186,19 @@ export function ProceduralEditorPage() {
|
|||||||
{isDirty && (
|
{isDirty && (
|
||||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
<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
|
<button
|
||||||
onClick={() => handleSave('draft')}
|
onClick={() => handleSave('draft')}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -272,10 +296,56 @@ export function ProceduralEditorPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step List — flex-1, scrolls independently */}
|
{/* Step List + AI Panel */}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
<StepList />
|
<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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user