feat: flow export/import + procedural Flow Assist (#96)

* feat: add flow export/import backend (migration, endpoints, schemas)

Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse

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

* feat: add flow export/import frontend + backend tests

Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types

Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
  access control, tag/category creation, version validation

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

* refactor: remove XML export, JSON-only for .rfflow files

- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types

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

* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt

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

* feat: add procedural flow support to AI chat builder (Flow Assist)

- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type

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

* feat: add flow type selection to Flow Assist entry points

- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references

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

* docs: update CLAUDE.md with AI chat builder and intake form learnings

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

* fix: refine assistant chat prompt for concise answers and focused questions

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

* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs

- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas

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

* docs: update CLAUDE.md with AI provider and chat input learnings

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

* docs: add editor-embedded Flow Assist design document

Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.

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

* docs: add editor-embedded Flow Assist implementation plan

25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.

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

* fix: use actual root node ID in orphan validation check

AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.

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

* feat: add config-driven AI model tier routing

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

* feat: extend AI chat session with tree_id and archived_at

Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.

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

* feat: add AI suggestion audit trail table

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

* feat: add action_type and focal_node_id to AI chat message API

- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
  AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged

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

* feat: route AI model selection through action-type config

Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.

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

* feat: add TypeScript types for editor-embedded AI

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

* feat: add shared ContextMenu component

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

* feat: add useEditorAI hook and editorAI API client

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

* feat: add EditorAIPanel component with Chat and Suggestions tabs

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

* feat: integrate AI panel, context menu, and ghost nodes in tree editor

- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling

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

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

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

* feat: add AI prompt dialog and wire into CreateFlowDropdown

Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.

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

* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button

- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown

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

* refactor: remove standalone Flow Assist page and old AI chat components

Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.

Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore

Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports

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

* feat: add delta response parsing and action-type prompt dispatch

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

* feat: add AI suggestion audit trail endpoints

Create/list/resolve endpoints for tracking AI-applied changes to flows.

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

* feat: add APScheduler task to auto-archive stale AI chat sessions

Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.

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

* docs: update project status for editor-embedded Flow Assist

- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)

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

* fix: use correct model alias in AI_MODEL_TIERS standard tier

The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.

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

* feat: send live flow context to AI Assist for full editor awareness

The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.

- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context

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

* feat: include flow name and description in AI Assist context

Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.

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

* fix: increase AI timeout to 120s and limit retries to 1

The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.

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

* fix: wire AI-generated flow structures into editor stores

The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration

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

* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers

#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()

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

* fix: move AI Assist panel to full-height side layout in both editors

The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.

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

* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)

Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.

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

* fix: AI Assist panel sits below topbar with slide-in animation

- Panel now uses top:56px to sit below the app shell topbar instead of
  covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
  bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism

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

* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)

Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.

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

* fix: AI Copilot panel as in-flow flex sibling in session navigation pages

Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.

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

* docs: remove duplicate CLAUDE.md lessons #47-48

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #96.
This commit is contained in:
chihlasm
2026-03-07 15:51:37 -05:00
committed by GitHub
parent 0fb1ef33a0
commit 0dc6123c0c
90 changed files with 7637 additions and 2472 deletions

View File

@@ -1,44 +0,0 @@
import { apiClient } from './client'
import type {
AIChatStartResponse,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportResponse,
} from '@/types'
export const aiChatApi = {
startSession: async (flowType: 'troubleshooting' | 'procedural'): Promise<AIChatStartResponse> => {
const { data } = await apiClient.post('/ai/chat/sessions', { flow_type: flowType })
return data
},
sendMessage: async (sessionId: string, content: string): Promise<AIChatMessageResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, { content })
return data
},
getSession: async (sessionId: string): Promise<AIChatSessionResponse> => {
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
return data
},
generateTree: async (sessionId: string): Promise<AIChatGenerateResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
return data
},
importTree: async (
sessionId: string,
params?: { name?: string; description?: string; category_id?: string; tags?: string[] }
): Promise<AIChatImportResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/import`, params || {})
return data
},
abandonSession: async (sessionId: string): Promise<void> => {
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
},
}
export default aiChatApi

View File

@@ -0,0 +1,44 @@
import { apiClient } from './client'
import type { AIActionType } from '@/types'
export interface SendMessageParams {
sessionId: string
content: string
actionType?: AIActionType
focalNodeId?: string | null
flowContext?: Record<string, unknown> | null
}
export const editorAIApi = {
startSession: async (flowType: 'troubleshooting' | 'procedural', treeId?: string) => {
const { data } = await apiClient.post('/ai/chat/sessions', {
flow_type: flowType,
tree_id: treeId,
})
return data
},
sendMessage: async ({ sessionId, content, actionType, focalNodeId, flowContext }: SendMessageParams) => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, {
content,
action_type: actionType || 'open_chat',
focal_node_id: focalNodeId,
flow_context: flowContext || undefined,
})
return data
},
getSession: async (sessionId: string) => {
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
return data
},
generateFull: async (sessionId: string) => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
return data
},
abandonSession: async (sessionId: string) => {
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
},
}

View File

@@ -0,0 +1,17 @@
import apiClient from './client'
import type { RFFlowFile, FlowImportResponse } from '@/types'
export const flowTransferApi = {
async exportFlow(treeId: string): Promise<Blob> {
const response = await apiClient.get(`/trees/${treeId}/export`, {
responseType: 'blob',
})
return response.data
},
async importFlow(data: RFFlowFile, nameOverride?: string): Promise<FlowImportResponse> {
const params = nameOverride ? { name_override: nameOverride } : undefined
const response = await apiClient.post('/trees/import', data, { params })
return response.data
},
}

View File

@@ -17,6 +17,6 @@ export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
export { default as feedbackApi } from './feedback'
export { default as aiBuilderApi } from './aiBuilder'
export { default as aiChatApi } from './aiChat'
export { copilotApi } from './copilot'
export { assistantChatApi } from './assistantChat'
export { flowTransferApi } from './flowTransfer'

View File

@@ -1,168 +0,0 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Modal } from '@/components/common/Modal'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { treesApi } from '@/api/trees'
import { toast } from '@/lib/toast'
import { WizardStepIndicator } from './WizardStepIndicator'
import { FoundationForm } from './FoundationForm'
import { BranchSelector } from './BranchSelector'
import { BranchDetailView } from './BranchDetailView'
import { TreePreviewCard } from './TreePreviewCard'
import { GeneratingAnimation } from './GeneratingAnimation'
interface AIFlowBuilderModalProps {
isOpen: boolean
onClose: () => void
}
export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps) {
const navigate = useNavigate()
const {
phase,
metadata,
assembledTree,
loadQuota,
scaffold,
reset,
} = useAIFlowBuilderStore()
// Load quota when modal opens
useEffect(() => {
if (isOpen) {
loadQuota()
}
}, [isOpen, loadQuota])
// Auto-trigger scaffold after conversation starts (ref prevents double-fire)
const hasTriggeredScaffold = useRef(false)
useEffect(() => {
// Reset guard when wizard resets to foundation (Start Over or close)
if (phase === 'foundation') {
hasTriggeredScaffold.current = false
return
}
if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
hasTriggeredScaffold.current = true
scaffold()
}
}, [phase, scaffold])
const handleClose = () => {
reset()
onClose()
}
const createTree = async () => {
if (!assembledTree) return null
try {
return await treesApi.create({
name: assembledTree.suggested_name,
description: assembledTree.suggested_description,
tree_structure: assembledTree.tree_structure,
tree_type: metadata.flow_type,
status: 'draft',
})
} catch {
toast.error('Failed to create flow. Please try again.')
return null
}
}
const handleOpenInEditor = async () => {
const tree = await createTree()
if (!tree) return
handleClose()
const editorPath =
metadata.flow_type === 'procedural'
? `/flows/${tree.id}/edit`
: `/trees/${tree.id}/edit`
navigate(editorPath)
}
const handleStartFlow = async () => {
const tree = await createTree()
if (!tree) return
handleClose()
const navigatePath =
metadata.flow_type === 'procedural'
? `/flows/${tree.id}/navigate`
: `/trees/${tree.id}/navigate`
navigate(navigatePath)
}
const handleBuildAnother = () => {
reset()
}
const getTitle = () => {
switch (phase) {
case 'foundation':
return 'Flow Assist'
case 'scaffolding':
case 'generating':
return 'AI Scaffold'
case 'detailing':
return 'Branch Detail'
case 'reviewing':
return 'Review & Assemble'
case 'error':
return 'Flow Assist'
default:
return 'Flow Assist'
}
}
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={getTitle()}
size="lg"
footer={
<WizardStepIndicator phase={phase} />
}
>
{phase === 'foundation' && <FoundationForm />}
{phase === 'scaffolding' && <BranchSelector />}
{phase === 'generating' && <GeneratingAnimation />}
{phase === 'detailing' && <BranchDetailView />}
{phase === 'reviewing' && (
<TreePreviewCard
onOpenInEditor={handleOpenInEditor}
onStartFlow={handleStartFlow}
onBuildAnother={handleBuildAnother}
/>
)}
{phase === 'error' && <ErrorView />}
</Modal>
)
}
function ErrorView() {
const { error, reset, setPhase } = useAIFlowBuilderStore()
return (
<div className="flex flex-col items-center gap-4 py-8">
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
{error || 'An unexpected error occurred.'}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPhase('foundation')}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Go Back
</button>
<button
type="button"
onClick={reset}
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
Start Over
</button>
</div>
</div>
)
}

View File

@@ -1,259 +0,0 @@
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft, Zap, Square } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { GeneratingAnimation } from './GeneratingAnimation'
import { cn } from '@/lib/utils'
export function BranchDetailView() {
const {
selectedBranches,
currentBranchIndex,
generateBranchDetail,
assemble,
isLoading,
error,
phase,
setError,
isGeneratingAll,
generateAllBranchDetails,
cancelGenerateAll,
} = useAIFlowBuilderStore()
const viewingIndex = currentBranchIndex
const setViewingIndex = (i: number) => useAIFlowBuilderStore.setState({ currentBranchIndex: i })
const currentBranch = selectedBranches[viewingIndex]
const allBranchesHaveDetail = selectedBranches.every((b) => b.steps)
const branchesWithDetail = selectedBranches.filter((b) => b.steps).length
const handleGenerate = async (branchName: string) => {
setError(null)
await generateBranchDetail(branchName)
}
const handleAssemble = async () => {
await assemble()
}
if (phase === 'generating' && isLoading) {
return (
<GeneratingAnimation
branchContext={
isGeneratingAll
? {
current: selectedBranches.filter((b) => b.steps).length + 1,
total: selectedBranches.length,
}
: undefined
}
/>
)
}
return (
<div className="flex flex-col gap-4">
{/* Content area */}
<div className="space-y-4">
{/* Branch tabs */}
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{selectedBranches.map((branch, i) => (
<button
key={branch.name}
type="button"
onClick={() => setViewingIndex(i)}
className={cn(
'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
viewingIndex === i
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border text-muted-foreground hover:bg-accent',
branch.steps && 'pr-2'
)}
>
{branch.name}
{branch.steps && (
<Check className="h-3 w-3 text-green-400" />
)}
</button>
))}
</div>
{/* Current branch detail */}
{currentBranch && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-foreground">{currentBranch.name}</h3>
<p className="text-xs text-muted-foreground">{currentBranch.description}</p>
</div>
</div>
{currentBranch.steps ? (
<div className="space-y-3">
{/* Mini tree preview */}
<div className="max-h-48 overflow-y-auto rounded-lg border border-border bg-accent/30 p-3">
<NodePreview node={currentBranch.steps} depth={0} />
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleGenerate(currentBranch.name)}
disabled={isLoading || isGeneratingAll}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<RefreshCw className="h-3 w-3" />
Regenerate
</button>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 rounded-lg border border-dashed border-border bg-accent/20 py-8">
<p className="text-sm text-muted-foreground">
Generate AI detail for this branch
</p>
{/* Generate All — primary action, shown when multiple branches remain */}
{selectedBranches.filter((b) => !b.steps).length > 1 && (
isGeneratingAll ? (
<button
type="button"
onClick={cancelGenerateAll}
className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-400/10 px-5 py-2.5 text-sm font-medium text-red-400 hover:bg-red-400/20"
>
<Square className="h-4 w-4" />
Stop Generating
</button>
) : (
<button
type="button"
onClick={generateAllBranchDetails}
disabled={isLoading}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
>
<Zap className="h-4 w-4" />
Generate All {selectedBranches.filter((b) => !b.steps).length} Branches
</button>
)
)}
{/* Divider + secondary actions */}
{selectedBranches.filter((b) => !b.steps).length > 1 && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="h-px w-8 bg-border" />
or
<div className="h-px w-8 bg-border" />
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleGenerate(currentBranch.name)}
disabled={isLoading || isGeneratingAll}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Generate this branch
</button>
<button
type="button"
onClick={() => {
if (viewingIndex < selectedBranches.length - 1) {
setViewingIndex(viewingIndex + 1)
}
}}
disabled={isGeneratingAll}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-50"
>
<SkipForward className="h-3 w-3" />
Skip
</button>
</div>
</div>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
</div>
{/* Navigation — sticky so it's always visible */}
<div className="sticky bottom-0 flex items-center justify-between border-t border-border bg-card pt-3 pb-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewingIndex(Math.max(0, viewingIndex - 1))}
disabled={viewingIndex === 0}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
>
<ChevronLeft className="h-3.5 w-3.5" />
Previous
</button>
<button
type="button"
onClick={() =>
setViewingIndex(Math.min(selectedBranches.length - 1, viewingIndex + 1))
}
disabled={viewingIndex === selectedBranches.length - 1}
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{branchesWithDetail}/{selectedBranches.length} detailed
</span>
<button
type="button"
onClick={handleAssemble}
disabled={!allBranchesHaveDetail || isLoading}
className={cn(
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
allBranchesHaveDetail && !isLoading
? 'hover:opacity-90'
: 'cursor-not-allowed opacity-50'
)}
>
Assemble Tree
</button>
</div>
</div>
</div>
)
}
/** Recursive mini-preview of a node tree */
function NodePreview({ node, depth }: { node: Record<string, unknown>; depth: number }) {
const type = node.type as string
const label =
type === 'decision'
? (node.question as string)
: (node.title as string) || 'Untitled'
const children = (node.children as Record<string, unknown>[]) || []
const typeColors: Record<string, string> = {
decision: 'bg-blue-400',
action: 'bg-amber-400',
solution: 'bg-green-400',
}
return (
<div style={{ marginLeft: depth * 16 }}>
<div className="flex items-center gap-2 py-0.5">
<div className={cn('h-2 w-2 rounded-full', typeColors[type] || 'bg-muted-foreground')} />
<span className="text-xs text-foreground truncate">{label}</span>
<span className="text-[10px] font-label text-muted-foreground">{type}</span>
</div>
{children.map((child) => (
<NodePreview key={child.id as string ?? crypto.randomUUID()} node={child} depth={depth + 1} />
))}
</div>
)
}

View File

@@ -1,292 +0,0 @@
import { useState } from 'react'
import { GripVertical, Plus, X, Pencil, Check, RefreshCw } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { cn } from '@/lib/utils'
import type { AIBranch } from '@/types'
export function BranchSelector() {
const {
suggestedBranches,
selectedBranches,
selectBranches,
setPhase,
scaffold,
isLoading,
error,
} = useAIFlowBuilderStore()
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const toggleBranch = (branch: AIBranch) => {
const isSelected = selectedBranches.some((b) => b.name === branch.name)
if (isSelected) {
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
} else {
selectBranches([...selectedBranches, branch])
}
}
const startEditing = (index: number) => {
const branch = selectedBranches[index]
setEditingIndex(index)
setEditName(branch.name)
setEditDesc(branch.description)
}
const saveEdit = () => {
if (editingIndex === null || !editName.trim()) return
const updated = [...selectedBranches]
updated[editingIndex] = {
...updated[editingIndex],
name: editName.trim(),
description: editDesc.trim(),
}
selectBranches(updated)
setEditingIndex(null)
}
const addCustomBranch = () => {
if (!newName.trim()) return
const branch: AIBranch = {
name: newName.trim(),
description: newDesc.trim(),
isCustom: true,
}
selectBranches([...selectedBranches, branch])
setNewName('')
setNewDesc('')
setShowAddForm(false)
}
const moveBranch = (fromIndex: number, direction: 'up' | 'down') => {
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
if (toIndex < 0 || toIndex >= selectedBranches.length) return
const updated = [...selectedBranches]
;[updated[fromIndex], updated[toIndex]] = [updated[toIndex], updated[fromIndex]]
selectBranches(updated)
}
const canProceed = selectedBranches.length >= 2
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
</p>
<button
type="button"
onClick={() => scaffold()}
disabled={isLoading}
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
title="Generate new suggestions"
>
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
Regenerate
</button>
</div>
{/* Branch list */}
<div className="space-y-2">
{suggestedBranches.map((branch) => {
const isSelected = selectedBranches.some((b) => b.name === branch.name)
const selectedIndex = selectedBranches.findIndex((b) => b.name === branch.name)
return (
<div
key={branch.name}
className={cn(
'flex items-start gap-3 rounded-lg border p-3 transition-colors cursor-pointer',
isSelected
? 'border-primary/30 bg-primary/5'
: 'border-border bg-card hover:bg-accent/50'
)}
onClick={() => toggleBranch(branch)}
>
<div
className={cn(
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border',
isSelected
? 'border-primary bg-primary text-white'
: 'border-border'
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<div className="flex-1 min-w-0">
{editingIndex !== null && selectedIndex === editingIndex ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full rounded border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
autoFocus
/>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="w-full rounded border border-border bg-card px-2 py-1 text-xs text-muted-foreground focus:border-primary focus:outline-none"
/>
<div className="flex gap-1">
<button
type="button"
onClick={saveEdit}
className="rounded bg-primary px-2 py-0.5 text-xs text-white"
>
Save
</button>
<button
type="button"
onClick={() => setEditingIndex(null)}
className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</div>
) : (
<>
<div className="text-sm font-medium text-foreground">{branch.name}</div>
<div className="text-xs text-muted-foreground">{branch.description}</div>
</>
)}
</div>
{isSelected && editingIndex !== selectedIndex && (
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => moveBranch(selectedIndex, 'up')}
disabled={selectedIndex === 0}
className="rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Move up"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => startEditing(selectedIndex)}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
})}
{/* Custom branches (not in suggested) */}
{selectedBranches
.filter((b) => b.isCustom)
.map((branch, i) => {
return (
<div
key={`custom-${i}`}
className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3"
>
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-primary bg-primary text-white">
<Check className="h-3 w-3" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{branch.name}</div>
<div className="text-xs text-muted-foreground">{branch.description}</div>
<span className="mt-1 inline-block rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-label text-primary">
Custom
</span>
</div>
<button
type="button"
onClick={() =>
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
}
className="rounded p-1 text-muted-foreground hover:text-red-400"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
{/* Add custom branch */}
{showAddForm ? (
<div className="space-y-2 rounded-lg border border-dashed border-border p-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Branch name"
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
autoFocus
/>
<input
type="text"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder="Brief description"
className="w-full rounded border border-border bg-card px-2 py-1.5 text-xs text-muted-foreground placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none"
/>
<div className="flex gap-1">
<button
type="button"
onClick={addCustomBranch}
disabled={!newName.trim()}
className="rounded bg-primary px-2.5 py-1 text-xs text-white disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="rounded border border-border px-2.5 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Plus className="h-4 w-4" />
Add custom branch
</button>
)}
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{selectedBranches.length} branch{selectedBranches.length !== 1 ? 'es' : ''} selected (min 2)
</span>
<button
type="button"
onClick={() => setPhase('detailing')}
disabled={!canProceed}
className={cn(
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
canProceed ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
)}
>
Continue to Detail
</button>
</div>
</div>
)
}

View File

@@ -1,163 +0,0 @@
import { useState } from 'react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { QuotaDisplay } from './QuotaDisplay'
import { cn } from '@/lib/utils'
export function FoundationForm() {
const { metadata, setMetadata, quota, start, isLoading, error } = useAIFlowBuilderStore()
const [tagInput, setTagInput] = useState('')
const canSubmit =
metadata.name.trim().length > 0 &&
metadata.description.trim().length > 0 &&
!isLoading &&
(quota?.allowed !== false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
await start()
}
const addTag = () => {
const tag = tagInput.trim()
if (tag && !metadata.environment_tags.includes(tag)) {
setMetadata({ environment_tags: [...metadata.environment_tags, tag] })
}
setTagInput('')
}
const removeTag = (tag: string) => {
setMetadata({ environment_tags: metadata.environment_tags.filter((t) => t !== tag) })
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{quota && <QuotaDisplay quota={quota} />}
{/* Flow Type */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Flow Type
</label>
<div className="flex gap-2">
{(['troubleshooting', 'procedural'] as const).map((type) => (
<button
key={type}
type="button"
onClick={() => setMetadata({ flow_type: type })}
className={cn(
'flex-1 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors',
metadata.flow_type === type
? 'border-primary/30 bg-primary/10 text-foreground'
: 'border-border bg-card text-muted-foreground hover:bg-accent'
)}
>
{type === 'troubleshooting' ? 'Troubleshooting' : 'Procedural'}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Flow Name
</label>
<input
type="text"
value={metadata.name}
onChange={(e) => setMetadata({ name: e.target.value })}
placeholder="e.g. DNS Resolution Failures"
className="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"
maxLength={255}
/>
</div>
{/* Description */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Description
</label>
<textarea
value={metadata.description}
onChange={(e) => setMetadata({ description: e.target.value })}
placeholder="Describe what this flow covers. The more detail you provide, the better the AI suggestions will be."
rows={4}
className="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 resize-none"
maxLength={2000}
/>
<p className="mt-1 text-right text-[10px] text-muted-foreground">
{metadata.description.length}/2000
</p>
</div>
{/* Environment Tags */}
<div>
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
Environment Tags <span className="normal-case tracking-normal text-muted-foreground/60">(optional)</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
placeholder="e.g. Windows Server, Active Directory"
className="flex-1 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"
/>
<button
type="button"
onClick={addTag}
className="rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Add
</button>
</div>
{metadata.environment_tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{metadata.environment_tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-card border border-border px-2.5 py-0.5 font-label text-xs text-muted-foreground"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-muted-foreground/60 hover:text-foreground"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={!canSubmit}
className={cn(
'w-full rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
)}
>
{isLoading ? 'Creating...' : 'Continue to AI Scaffold'}
</button>
</form>
)
}

View File

@@ -1,24 +0,0 @@
interface GeneratingAnimationProps {
branchContext?: { current: number; total: number }
}
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-10">
{/* Spinner */}
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
{/* Branch context (Generate All mode) */}
{branchContext ? (
<>
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
Branch {branchContext.current} of {branchContext.total}
</p>
<p className="text-sm text-muted-foreground">Generating branch detail...</p>
</>
) : (
<p className="text-sm text-muted-foreground">Generating...</p>
)}
</div>
)
}

View File

@@ -1,48 +0,0 @@
import { cn } from '@/lib/utils'
import type { AIQuotaStatus } from '@/types'
interface QuotaDisplayProps {
quota: AIQuotaStatus
compact?: boolean
}
export function QuotaDisplay({ quota, compact = false }: QuotaDisplayProps) {
if (!quota.ai_enabled) return null
const monthlyRemaining =
quota.monthly_limit !== null
? Math.max(0, quota.monthly_limit - quota.monthly_used)
: null
const getColor = () => {
if (!quota.allowed) return 'text-red-400'
if (monthlyRemaining !== null && monthlyRemaining <= 1) return 'text-amber-400'
return 'text-green-400'
}
if (compact) {
return (
<span className={cn('text-xs font-label', getColor())}>
{monthlyRemaining !== null
? `${monthlyRemaining}/${quota.monthly_limit} builds`
: 'Unlimited'}
</span>
)
}
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-accent/50 px-3 py-1.5">
<div className={cn('h-2 w-2 rounded-full', getColor().replace('text-', 'bg-'))} />
<span className="text-xs text-muted-foreground">
{monthlyRemaining !== null ? (
<>
<span className={cn('font-medium', getColor())}>{monthlyRemaining}</span>
{' '}of {quota.monthly_limit} AI builds remaining
</>
) : (
'Unlimited AI builds'
)}
</span>
</div>
)
}

View File

@@ -1,98 +0,0 @@
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw, Play } from 'lucide-react'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
import { cn } from '@/lib/utils'
interface TreePreviewCardProps {
onOpenInEditor: () => void
onStartFlow: () => void
onBuildAnother: () => void
}
export function TreePreviewCard({ onOpenInEditor, onStartFlow, onBuildAnother }: TreePreviewCardProps) {
const { assembledTree, isLoading } = useAIFlowBuilderStore()
if (!assembledTree) return null
const { summary } = assembledTree
const stats = [
{ label: 'Nodes', value: summary.node_count, icon: Layers },
{ label: 'Decisions', value: summary.decision_count, icon: GitBranch },
{ label: 'Solutions', value: summary.solution_count, icon: CheckCircle },
{ label: 'Depth', value: summary.depth, icon: Layers },
]
return (
<div className="space-y-4">
<div className="text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-400/10">
<CheckCircle className="h-6 w-6 text-green-400" />
</div>
<h3 className="text-lg font-semibold text-foreground">
Tree Assembled
</h3>
<p className="mt-1 text-sm text-muted-foreground">
&quot;{assembledTree.suggested_name}&quot; is ready to review in the editor.
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-4 gap-2">
{stats.map(({ label, value, icon: Icon }) => (
<div
key={label}
className="flex flex-col items-center rounded-lg border border-border bg-accent/30 p-2.5"
>
<Icon className="mb-1 h-4 w-4 text-muted-foreground" />
<span className="text-lg font-semibold text-gradient-brand">{value}</span>
<span className="text-[10px] font-label uppercase tracking-wide text-muted-foreground">
{label}
</span>
</div>
))}
</div>
{/* Description */}
{assembledTree.suggested_description && (
<div className="rounded-lg border border-border bg-accent/20 p-3">
<p className="text-xs text-muted-foreground">{assembledTree.suggested_description}</p>
</div>
)}
{/* Actions */}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onStartFlow}
disabled={isLoading}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90 disabled:opacity-50'
)}
>
<Play className="h-4 w-4" />
Start Flow
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onOpenInEditor}
disabled={isLoading}
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<ArrowRight className="h-4 w-4" />
Open in Editor
</button>
<button
type="button"
onClick={onBuildAnother}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<RotateCcw className="h-4 w-4" />
Build Another
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,70 +0,0 @@
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AIWizardPhase } from '@/types'
const STEPS = [
{ key: 'foundation', label: 'Foundation' },
{ key: 'scaffolding', label: 'Scaffold' },
{ key: 'detailing', label: 'Detail' },
{ key: 'reviewing', label: 'Review' },
] as const
const PHASE_ORDER: Record<string, number> = {
foundation: 0,
scaffolding: 1,
generating: 1,
detailing: 2,
reviewing: 3,
completed: 4,
error: -1,
}
interface WizardStepIndicatorProps {
phase: AIWizardPhase
}
export function WizardStepIndicator({ phase }: WizardStepIndicatorProps) {
const currentIndex = PHASE_ORDER[phase] ?? 0
return (
<div className="flex items-center gap-1 px-2">
{STEPS.map((step, i) => {
const isCompleted = currentIndex > i
const isCurrent = currentIndex === i
return (
<div key={step.key} className="flex items-center gap-1">
{i > 0 && (
<div
className={cn(
'h-px w-4 sm:w-6',
isCompleted ? 'bg-primary' : 'bg-border'
)}
/>
)}
<div className="flex items-center gap-1.5">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium',
isCompleted && 'bg-primary text-white',
isCurrent && 'bg-primary/20 text-primary ring-1 ring-primary/40',
!isCompleted && !isCurrent && 'bg-accent text-muted-foreground'
)}
>
{isCompleted ? <Check className="h-3 w-3" /> : i + 1}
</div>
<span
className={cn(
'hidden text-xs sm:inline',
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
>
{step.label}
</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,72 +0,0 @@
import { useState, useRef, useCallback } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChatInputProps {
onSend: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = useCallback(() => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [value, disabled, onSend])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleInput = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 160) + 'px'
}
}
return (
<div className="flex items-end gap-2 border-t border-border bg-card px-4 py-3">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2',
'text-sm text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
'max-h-40'
)}
/>
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Send className="h-4 w-4" />
</button>
</div>
)
}

View File

@@ -1,41 +0,0 @@
import { Bot, User } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { cn } from '@/lib/utils'
import type { ChatMessage as ChatMessageType } from '@/types'
interface ChatMessageProps {
message: ChatMessageType
}
export function ChatMessage({ message }: ChatMessageProps) {
const isAI = message.role === 'assistant'
return (
<div className={cn('flex gap-3', isAI ? 'items-start' : 'items-start justify-end')}>
{isAI && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div
className={cn(
'max-w-[85%] rounded-xl px-4 py-3',
isAI
? 'bg-card border border-border'
: 'bg-primary/10 border border-primary/20'
)}
>
{isAI ? (
<MarkdownContent content={message.content} className="text-sm" />
) : (
<p className="text-sm text-foreground whitespace-pre-wrap">{message.content}</p>
)}
</div>
{!isAI && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent">
<User className="h-4 w-4 text-foreground" />
</div>
)}
</div>
)
}

View File

@@ -1,47 +0,0 @@
import { useEffect, useRef } from 'react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { Spinner } from '@/components/common/Spinner'
import type { ChatMessage as ChatMessageType } from '@/types'
interface ChatPanelProps {
messages: ChatMessageType[]
isResponding: boolean
onSendMessage: (content: string) => void
disabled?: boolean
}
export function ChatPanel({ messages, isResponding, onSendMessage, disabled }: ChatPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isResponding])
return (
<div className="flex h-full flex-col">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((msg, i) => (
<ChatMessage key={i} message={msg} />
))}
{isResponding && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Thinking...</span>
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={onSendMessage}
disabled={disabled || isResponding}
placeholder={isResponding ? 'Waiting for response...' : 'Type a message...'}
/>
</div>
)
}

View File

@@ -1,98 +0,0 @@
import { Sparkles, Save, RotateCcw, Loader2 } from 'lucide-react'
import { PhaseIndicator } from './PhaseIndicator'
import { cn } from '@/lib/utils'
import type { InterviewPhase } from '@/types'
interface ChatToolbarProps {
currentPhase: InterviewPhase
status: 'idle' | 'active' | 'completed' | 'abandoned'
isGenerating: boolean
hasGeneratedTree: boolean
isSaving: boolean
onGenerate: () => void
onSave: () => void
onReset: () => void
}
export function ChatToolbar({
currentPhase,
status,
isGenerating,
hasGeneratedTree,
isSaving,
onGenerate,
onSave,
onReset,
}: ChatToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Sparkles className="h-4 w-4 text-primary" />
Flow Assist
</div>
<PhaseIndicator currentPhase={currentPhase} />
</div>
<div className="flex items-center gap-2">
{status === 'active' && !hasGeneratedTree && (
<button
onClick={onGenerate}
disabled={isGenerating}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isGenerating ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
Generate Flow
</>
)}
</button>
)}
{hasGeneratedTree && (
<button
onClick={onSave}
disabled={isSaving}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save to Flow Library
</>
)}
</button>
)}
<button
onClick={onReset}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Start Over
</button>
</div>
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { TreeDeciduous } from 'lucide-react'
export function EmptyPreview() {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<TreeDeciduous className="h-12 w-12 text-muted-foreground/30 mb-4" />
<h3 className="text-sm font-medium text-muted-foreground mb-1">Flow Preview</h3>
<p className="text-xs text-muted-foreground/70 max-w-48">
Your flow will appear here as you describe it to the AI
</p>
</div>
)
}

View File

@@ -1,50 +0,0 @@
import { cn } from '@/lib/utils'
import type { InterviewPhase } from '@/types'
const PHASES: { key: InterviewPhase; label: string }[] = [
{ key: 'scoping', label: 'Scoping' },
{ key: 'discovery', label: 'Discovery' },
{ key: 'enrichment', label: 'Enrichment' },
{ key: 'review', label: 'Review' },
{ key: 'generation', label: 'Generate' },
]
interface PhaseIndicatorProps {
currentPhase: InterviewPhase
}
export function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
const currentIndex = PHASES.findIndex((p) => p.key === currentPhase)
return (
<div className="flex items-center gap-1">
{PHASES.map((phase, i) => {
const isActive = phase.key === currentPhase
const isCompleted = i < currentIndex
return (
<div key={phase.key} className="flex items-center">
{i > 0 && (
<div
className={cn(
'mx-1 h-px w-4',
isCompleted ? 'bg-primary' : 'bg-border'
)}
/>
)}
<span
className={cn(
'font-label text-[0.6875rem] uppercase tracking-wide px-2 py-0.5 rounded',
isActive && 'text-primary bg-primary/10 font-medium',
isCompleted && 'text-primary',
!isActive && !isCompleted && 'text-muted-foreground'
)}
>
{phase.label}
</span>
</div>
)
})}
</div>
)
}

View File

@@ -1,80 +0,0 @@
import { useState, useMemo, useCallback } from 'react'
import { TreePreviewNode } from '@/components/tree-preview/TreePreviewNode'
import type { SharedLinksMap } from '@/components/tree-preview/TreePreviewPanel'
import type { TreeStructure } from '@/types'
interface StaticTreePreviewProps {
tree: TreeStructure
name?: string
}
function findNodeInTree(nodeId: string, tree: TreeStructure): TreeStructure | null {
if (tree.id === nodeId) return tree
if (tree.children) {
for (const child of tree.children) {
const found = findNodeInTree(nodeId, child)
if (found) return found
}
}
return null
}
function buildSharedLinksMap(node: TreeStructure, map: SharedLinksMap = new Map()): SharedLinksMap {
const nodeLabel = node.type === 'decision' ? node.question : node.title
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id) {
const existing = map.get(opt.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(opt.next_node_id, existing)
}
}
}
if (node.type === 'action' && node.next_node_id) {
const existing = map.get(node.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(node.next_node_id, existing)
}
if (node.children) {
for (const child of node.children) {
buildSharedLinksMap(child, map)
}
}
return map
}
export function StaticTreePreview({ tree, name }: StaticTreePreviewProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const findNode = useCallback(
(nodeId: string) => findNodeInTree(nodeId, tree),
[tree]
)
const sharedLinksMap = useMemo(() => buildSharedLinksMap(tree), [tree])
return (
<div className="flex h-full flex-col">
<div className="border-b border-border px-4 py-2">
<h3 className="text-sm font-semibold text-foreground">
Preview: {name || 'Untitled Flow'}
</h3>
<p className="text-xs text-muted-foreground">
Click a node to select
</p>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="inline-block min-w-full">
<TreePreviewNode
node={tree}
selectedNodeId={selectedNodeId}
onSelect={setSelectedNodeId}
depth={0}
findNode={findNode}
sharedLinksMap={sharedLinksMap}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
export interface ContextMenuPosition {
x: number
y: number
}
export interface ContextMenuItem {
id: string
label: string
icon?: React.ReactNode
onClick: () => void
variant?: 'default' | 'danger'
separator?: boolean
}
interface ContextMenuProps {
position: ContextMenuPosition
items: ContextMenuItem[]
onClose: () => void
}
export function ContextMenu({ position, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const handleClickOutside = useCallback(
(e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
},
[onClose]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose]
)
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [handleClickOutside, handleKeyDown])
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return
const rect = menuRef.current.getBoundingClientRect()
const el = menuRef.current
if (position.x + rect.width > window.innerWidth) {
el.style.left = `${position.x - rect.width}px`
}
if (position.y + rect.height > window.innerHeight) {
el.style.top = `${position.y - rect.height}px`
}
}, [position])
return (
<div
ref={menuRef}
style={{
position: 'fixed',
zIndex: 100,
left: position.x,
top: position.y,
}}
className="min-w-[200px] rounded-xl border border-border bg-card p-1 shadow-lg backdrop-blur-md"
>
{items.map((item) => (
<div key={item.id}>
{item.separator && (
<div className="my-1 border-t border-border" />
)}
<button
onClick={() => {
item.onClick()
onClose()
}}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
item.variant === 'danger'
? 'text-rose-400 hover:bg-rose-500/10'
: 'text-foreground hover:bg-[rgba(255,255,255,0.06)]'
)}
>
{item.icon && (
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
{item.label}
</button>
</div>
))}
</div>
)
}

View File

@@ -1,11 +1,15 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
import { cn } from '@/lib/utils'
import { editorAIApi } from '@/api/editorAI'
import { apiClient } from '@/api/client'
import { AIPromptDialog } from '@/components/editor-ai/AIPromptDialog'
type AIFlowType = 'troubleshooting' | 'procedural' | 'maintenance'
interface CreateFlowDropdownProps {
aiEnabled: boolean
onOpenAIBuilder: () => void
className?: string
/** Button label — defaults to "Create Flow" */
label?: string
@@ -13,11 +17,49 @@ interface CreateFlowDropdownProps {
export function CreateFlowDropdown({
aiEnabled,
onOpenAIBuilder,
className,
label = 'Create Flow',
}: CreateFlowDropdownProps) {
const [showMenu, setShowMenu] = useState(false)
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<AIFlowType>('troubleshooting')
const navigate = useNavigate()
const handleAIGenerate = async (prompt: string) => {
// Start an AI session
const session = await editorAIApi.startSession(
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
)
const sessionId = session.id
// Send the user's prompt
await editorAIApi.sendMessage({
sessionId,
content: prompt,
actionType: 'generate_full',
})
// Generate the full flow
await editorAIApi.generateFull(sessionId)
// Import to create the tree
const { data: importResult } = await apiClient.post(
`/ai/chat/sessions/${sessionId}/import`,
{}
)
const treeId = importResult.tree_id
// Navigate to the editor
if (aiPromptFlowType === 'troubleshooting') {
navigate(`/trees/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
} else {
navigate(`/flows/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
}
}
return (
<div className={cn('relative', className)}>
@@ -32,62 +74,107 @@ export function CreateFlowDropdown({
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
<div className="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
{/* Troubleshooting */}
<Link
to="/trees/new"
onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<FolderTree className="h-4 w-4 text-muted-foreground" />
<div>
<div className="flex-1">
<div className="font-medium">Troubleshooting Tree</div>
<div className="text-xs text-muted-foreground">Branching decision flow</div>
</div>
</Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('troubleshooting')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" />
{/* Procedural */}
<Link
to="/flows/new"
onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<div>
<div className="flex-1">
<div className="font-medium">Procedural Flow</div>
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
</div>
</Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('procedural')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" />
{/* Maintenance */}
<Link
to="/flows/new?type=maintenance"
onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<Wrench className="h-4 w-4 text-amber-400" />
<div>
<div className="flex-1">
<div className="font-medium">Maintenance Flow</div>
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
</div>
</Link>
{aiEnabled && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
onClick={() => {
setShowMenu(false)
onOpenAIBuilder()
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-4 w-4 text-primary" />
<div className="text-left">
<div className="font-medium">Flow Assist</div>
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
</div>
</button>
</>
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('maintenance')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
</div>
</>
)}
<AIPromptDialog
isOpen={aiPromptOpen}
onClose={() => setAiPromptOpen(false)}
onGenerate={handleAIGenerate}
flowType={aiPromptFlowType}
/>
</div>
)
}

View File

@@ -88,9 +88,9 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
return (
<div
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
className="flex flex-col border-l shrink-0"
style={{
width: '400px',
width: '380px',
background: 'rgba(16, 17, 20, 0.95)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
@@ -174,6 +174,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
<Send size={16} />
</button>
</div>
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
</div>
</div>
)

View File

@@ -0,0 +1,111 @@
import { useState } from 'react'
import { Sparkles, Loader2, AlertCircle } from 'lucide-react'
interface AIPromptDialogProps {
isOpen: boolean
onClose: () => void
onGenerate: (prompt: string) => Promise<void>
flowType: 'troubleshooting' | 'procedural' | 'maintenance'
}
const FLOW_TYPE_LABELS = {
troubleshooting: 'Troubleshooting Flow',
procedural: 'Project Flow',
maintenance: 'Maintenance Flow',
}
export function AIPromptDialog({
isOpen,
onClose,
onGenerate,
flowType,
}: AIPromptDialogProps) {
const [prompt, setPrompt] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const handleGenerate = async () => {
if (!prompt.trim()) return
setIsGenerating(true)
setError(null)
try {
await onGenerate(prompt)
setPrompt('')
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Generation failed. Please try again.')
} finally {
setIsGenerating(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !isGenerating && onClose()}
/>
{/* Dialog */}
<div className="glass-card-static relative w-full max-w-lg p-6 shadow-xl">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="h-5 w-5 text-primary" />
<h2 className="text-lg font-heading font-semibold text-foreground">
AI-Assisted {FLOW_TYPE_LABELS[flowType]}
</h2>
</div>
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build and AI will generate a starting structure for you.
</p>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={`Example: "A flow for troubleshooting VPN connectivity issues when users can't connect to the corporate network"`}
rows={4}
disabled={isGenerating}
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none disabled:opacity-50"
autoFocus
/>
{error && (
<div className="mt-3 flex items-start gap-2 rounded-lg bg-rose-500/10 border border-rose-500/20 px-3 py-2">
<AlertCircle className="h-4 w-4 text-rose-400 mt-0.5 shrink-0" />
<p className="text-sm text-rose-400">{error}</p>
</div>
)}
<div className="mt-4 flex justify-end gap-3">
<button
onClick={onClose}
disabled={isGenerating}
className="rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleGenerate}
disabled={!prompt.trim() || isGenerating}
className="flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import { useRef, useEffect } from 'react'
import { Send, Sparkles, Loader2 } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import type { EditorAIChatMessage } from '@/types'
interface ChatTabProps {
messages: EditorAIChatMessage[]
input: string
onInputChange: (value: string) => void
onSend: () => void
isLoading: boolean
}
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Focus input when panel mounts
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus())
}, [])
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (input.trim() && !isLoading) onSend()
}
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
<Sparkles className="h-8 w-8 mb-2 opacity-40" />
<p>Ask me to help build your flow</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
msg.role === 'user'
? 'bg-primary/15 text-foreground'
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
}`}
>
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
<Loader2 size={16} className="animate-spin text-primary" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask AI to help..."
rows={1}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={isLoading}
/>
<button
onClick={onSend}
disabled={!input.trim() || isLoading}
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={16} />
</button>
</div>
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react'
import { X, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { NodeSummary } from './NodeSummary'
import { ChatTab } from './ChatTab'
import { SuggestionsTab } from './SuggestionsTab'
import type { EditorAIChatMessage, AISuggestion } from '@/types'
interface EditorAIPanelProps {
isOpen: boolean
onClose: () => void
focalNode?: { id: string; type: string; question?: string; title?: string; description?: string } | null
flowName?: string
flowType?: string
nodeCount?: number
messages: EditorAIChatMessage[]
input: string
onInputChange: (value: string) => void
onSend: () => void
isLoading: boolean
suggestions: AISuggestion[]
}
type Tab = 'chat' | 'suggestions'
export function EditorAIPanel({
isOpen,
onClose,
focalNode,
flowName,
flowType,
nodeCount,
messages,
input,
onInputChange,
onSend,
isLoading,
suggestions,
}: EditorAIPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>('chat')
if (!isOpen) return null
const pendingCount = suggestions.filter((s) => s.status === 'pending').length
return (
<div
className="flex h-full w-[380px] shrink-0 flex-col border-l"
style={{
background: 'rgba(16, 17, 20, 0.95)',
backdropFilter: 'var(--glass-blur)',
WebkitBackdropFilter: 'var(--glass-blur)',
borderColor: 'var(--glass-border)',
animation: 'slideInRight 200ms ease-out',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-primary" />
<span className="text-sm font-semibold text-foreground">AI Assist</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} />
</button>
</div>
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
{/* Tabs */}
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<button
onClick={() => setActiveTab('chat')}
className={cn(
'flex-1 px-3 py-2 text-xs font-medium transition-colors',
activeTab === 'chat'
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Chat
</button>
<button
onClick={() => setActiveTab('suggestions')}
className={cn(
'flex-1 px-3 py-2 text-xs font-medium transition-colors relative',
activeTab === 'suggestions'
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Suggestions
{pendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[0.5625rem] text-primary">
{pendingCount}
</span>
)}
</button>
</div>
{activeTab === 'chat' ? (
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
) : (
<SuggestionsTab suggestions={suggestions} />
)}
</div>
)
}

View File

@@ -0,0 +1,59 @@
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
interface NodeSummaryProps {
node?: { id: string; type: string; question?: string; title?: string; description?: string } | null
flowName?: string
flowType?: string
nodeCount?: number
}
const NODE_ICONS: Record<string, typeof HelpCircle> = {
decision: HelpCircle,
action: Zap,
solution: CheckCircle,
}
const NODE_COLORS: Record<string, string> = {
decision: 'text-blue-400',
action: 'text-yellow-400',
solution: 'text-green-400',
}
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
if (!node) {
return (
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-center gap-2">
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-foreground truncate">
{flowName || 'Untitled Flow'}
</span>
</div>
<div className="mt-1 flex items-center gap-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
<span>{flowType || 'flow'}</span>
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
</div>
</div>
)
}
const Icon = NODE_ICONS[node.type] || FileText
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
return (
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-center gap-2">
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{node.type}
</span>
</div>
<p className="mt-1 text-sm font-medium text-foreground truncate">
{node.question || node.title || node.id}
</p>
{node.description && (
<p className="mt-0.5 text-xs text-muted-foreground truncate">{node.description}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Check, X, Clock } from 'lucide-react'
import type { AISuggestion } from '@/types'
interface SuggestionsTabProps {
suggestions: AISuggestion[]
}
const STATUS_CONFIG = {
accepted: { icon: Check, color: 'text-emerald-400', label: 'Accepted' },
dismissed: { icon: X, color: 'text-rose-400', label: 'Dismissed' },
pending: { icon: Clock, color: 'text-amber-400', label: 'Pending' },
} as const
export function SuggestionsTab({ suggestions }: SuggestionsTabProps) {
if (suggestions.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground p-6">
No AI suggestions yet
</div>
)
}
return (
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{suggestions.map((s) => {
const config = STATUS_CONFIG[s.status]
const StatusIcon = config.icon
return (
<div key={s.id} className="rounded-lg border border-border bg-[rgba(255,255,255,0.02)] px-3 py-2">
<div className="flex items-center justify-between">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
{s.action_type.replace(/_/g, ' ')}
</span>
<span className={`flex items-center gap-1 text-xs ${config.color}`}>
<StatusIcon className="h-3 w-3" />
{config.label}
</span>
</div>
{s.target_node_id && (
<p className="mt-1 text-xs text-muted-foreground truncate">Node: {s.target_node_id}</p>
)}
<p className="mt-0.5 font-label text-[0.625rem] text-[#5a6170]">
{new Date(s.created_at).toLocaleDateString()}
</p>
</div>
)
})}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare, BookOpen } from 'lucide-react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
@@ -81,7 +81,6 @@ export function Sidebar() {
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
@@ -114,7 +113,6 @@ export function Sidebar() {
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />

View File

@@ -0,0 +1,100 @@
import { useState, useEffect } from 'react'
import { Download, X } from 'lucide-react'
import { flowTransferApi } from '@/api/flowTransfer'
import { toast } from '@/lib/toast'
interface ExportFlowModalProps {
treeId: string
treeName: string
onClose: () => void
}
function slugify(name: string): string {
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[-\s]+/g, '-')
}
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleExport = async () => {
setIsExporting(true)
try {
const blob = await flowTransferApi.exportFlow(treeId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${slugify(treeName)}.rfflow`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Flow exported successfully')
onClose()
} catch (err) {
console.error('Export failed:', err)
toast.error('Failed to export flow')
} finally {
setIsExporting(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-sm 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">
<Download className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Export 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 */}
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
</p>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<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
onClick={handleExport}
disabled={isExporting}
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"
>
<Download className="h-4 w-4" />
{isExporting ? 'Exporting…' : 'Download .rfflow'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
import { useState, useEffect, useRef } from 'react'
import { FileUp, X, AlertTriangle } from 'lucide-react'
import { flowTransferApi } from '@/api/flowTransfer'
import { parseRFFlowFile, RFFlowParseError } from '@/lib/rfflowParser'
import type { RFFlowFile } from '@/types'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
import { getTreeEditorPath } from '@/lib/routing'
interface ImportFlowModalProps {
onClose: () => void
}
const TYPE_LABELS: Record<string, string> = {
troubleshooting: 'Troubleshooting',
procedural: 'Project',
maintenance: 'Maintenance',
}
export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const [step, setStep] = useState<'pick' | 'preview'>('pick')
const [parsed, setParsed] = useState<RFFlowFile | null>(null)
const [nameOverride, setNameOverride] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
const [isImporting, setIsImporting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleFile = async (file: File) => {
setParseError(null)
if (!file.name.endsWith('.rfflow')) {
setParseError('File must have .rfflow extension')
return
}
try {
const text = await file.text()
const data = parseRFFlowFile(text)
setParsed(data)
setNameOverride(data.flow.name)
setStep('preview')
} catch (err) {
if (err instanceof RFFlowParseError) {
setParseError(err.message)
} else {
setParseError('Failed to read file')
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}
const handleImport = async () => {
if (!parsed) return
setIsImporting(true)
try {
const overrideName = nameOverride.trim() !== parsed.flow.name ? nameOverride.trim() : undefined
const result = await flowTransferApi.importFlow(parsed, overrideName)
toast.success(`Imported "${result.name}" as draft`)
onClose()
navigate(getTreeEditorPath(result.tree_id, result.tree_type))
} catch (err) {
console.error('Import failed:', err)
toast.error('Failed to import flow')
} finally {
setIsImporting(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">
<FileUp className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Import 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 */}
<div className="px-5 py-4">
{step === 'pick' && (
<div className="space-y-3">
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors cursor-pointer',
isDragging
? 'border-primary/50 bg-primary/5'
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<FileUp className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-foreground">
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">JSON format</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".rfflow"
onChange={handleFileChange}
className="hidden"
/>
{parseError && (
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-400" />
<p className="text-xs text-rose-400">{parseError}</p>
</div>
)}
</div>
)}
{step === 'preview' && parsed && (
<div className="space-y-4">
{/* Editable name */}
<div>
<label htmlFor="import-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Name
</label>
<input
id="import-name"
type="text"
value={nameOverride}
onChange={(e) => setNameOverride(e.target.value)}
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>
{/* Flow info */}
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Type:</span>
<span className="rounded bg-primary/10 px-2 py-0.5 font-label text-primary">
{TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type}
</span>
</div>
{parsed.flow.description && (
<div>
<span className="text-muted-foreground">Description:</span>
<p className="mt-0.5 text-foreground line-clamp-2">{parsed.flow.description}</p>
</div>
)}
{parsed.flow.category && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Category:</span>
<span className="text-foreground">{parsed.flow.category.name}</span>
</div>
)}
{parsed.flow.tags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground">Tags:</span>
{parsed.flow.tags.map((tag) => (
<span key={tag} className="rounded bg-card border border-border px-2 py-0.5 font-label text-foreground">
{tag}
</span>
))}
</div>
)}
{parsed.flow.author_name && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Original author:</span>
<span className="text-foreground">{parsed.flow.author_name}</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Version:</span>
<span className="text-foreground">v{parsed.flow.version}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Flow will be imported as a <span className="font-medium text-foreground">draft</span>.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
{step === 'preview' && (
<button
onClick={() => { setStep('pick'); setParsed(null); setParseError(null) }}
className="mr-auto rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Back
</button>
)}
<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>
{step === 'preview' && (
<button
onClick={handleImport}
disabled={isImporting || !nameOverride.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"
>
<FileUp className="h-4 w-4" />
{isImporting ? 'Importing…' : 'Import as Draft'}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -13,6 +13,7 @@ interface TreeGridViewProps {
onFolderCreated: (parentId?: string | null) => void
onDeleteTree: (tree: TreeListItem) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
@@ -24,6 +25,7 @@ export function TreeGridView({
onTagClick,
onDeleteTree,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
@@ -111,6 +113,20 @@ export function TreeGridView({
v{tree.version} · {tree.usage_count} uses
</span>
<div className="flex items-center gap-2">
{onExportTree && (
<button
type="button"
onClick={() => onExportTree(tree.id)}
className={cn(
'rounded-md border border-border p-2 text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
title="Export flow"
aria-label="Export flow"
>
<Download className="h-4 w-4" />
</button>
)}
{onForkTree && (
<button
type="button"

View File

@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -13,6 +13,7 @@ interface TreeListViewProps {
onFolderCreated: (parentId?: string | null) => void
onDeleteTree: (tree: TreeListItem) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
@@ -24,6 +25,7 @@ export function TreeListView({
onTagClick,
onDeleteTree,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
@@ -115,6 +117,20 @@ export function TreeListView({
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
</button>
)}
{onExportTree && (
<button
type="button"
onClick={() => onExportTree(tree.id)}
className={cn(
'rounded-md border border-border p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
title="Export flow"
aria-label="Export flow"
>
<Download className="h-4 w-4" />
</button>
)}
{onForkTree && (
<button
type="button"

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { cn } from '@/lib/utils'
@@ -15,6 +15,7 @@ interface TreeTableViewProps {
onDeleteTree: (tree: TreeListItem) => void
onSortChange?: (sortBy: string) => void
onForkTree?: (treeId: string) => void
onExportTree?: (treeId: string) => void
pinnedTreeIds?: Set<string>
onTogglePin?: (treeId: string) => void
pinLoadingTreeIds?: Set<string>
@@ -29,6 +30,7 @@ export function TreeTableView({
onDeleteTree,
onSortChange,
onForkTree,
onExportTree,
pinnedTreeIds,
onTogglePin,
pinLoadingTreeIds,
@@ -226,6 +228,20 @@ export function TreeTableView({
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{onExportTree && (
<button
type="button"
onClick={() => onExportTree(tree.id)}
className={cn(
'rounded-md border border-border p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
title="Export flow"
aria-label="Export flow"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
{onForkTree && (
<button
type="button"

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

@@ -31,9 +31,10 @@ interface FlowCanvasProps {
selectedNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
}
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
const { fitView, setCenter } = useReactFlow()
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
const [minimapVisible, setMinimapVisible] = useState(true)
@@ -46,7 +47,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
return {
...n,
selected: n.id === selectedNodeId,
data: { ...data, onToggleCollapse: toggleCollapse },
data: { ...data, onToggleCollapse: toggleCollapse, onContextMenu: onNodeContextMenu },
}
}
if (n.type === 'answerStub') {
@@ -59,7 +60,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
}
return n
})
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeContextMenu])
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)

View File

@@ -41,10 +41,14 @@ export interface FlowCanvasNodeData {
hasValidationErrors: boolean
isNew: boolean
onToggleCollapse: (nodeId: string) => void
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
onAcceptSuggestion?: (nodeId: string) => void
onDismissSuggestion?: (nodeId: string) => void
}
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
const isGhost = !!(node as unknown as Record<string, unknown>)._suggestion
const nodeType = node.type as Exclude<NodeType, 'answer'>
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
const Icon = config.icon
@@ -61,10 +65,12 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
<div
onContextMenu={(e) => onContextMenu?.(e, node.id)}
className={cn(
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
config.borderClass,
selected && 'ring-1 ring-primary shadow-md'
selected && 'ring-1 ring-primary shadow-md',
isGhost && 'border-dashed !border-primary/40 opacity-60'
)}
>
{/* Header */}
@@ -142,6 +148,30 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
</button>
</div>
)}
{/* Ghost node accept/dismiss overlay */}
{isGhost && (
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2 px-3 pb-2">
<button
onClick={(e) => {
e.stopPropagation()
onAcceptSuggestion?.(node.id)
}}
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()
onDismissSuggestion?.(node.id)
}}
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>
)}
</div>
{/* Source handle at bottom */}

View File

@@ -19,6 +19,7 @@ interface TreeEditorLayoutProps {
editingNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
}
export function TreeEditorLayout({
@@ -28,6 +29,7 @@ export function TreeEditorLayout({
editingNodeId,
onNodeSelect,
onSelectAnswerType,
onNodeContextMenu,
}: TreeEditorLayoutProps) {
const editorMode = useTreeEditorStore(s => s.editorMode)
@@ -70,6 +72,7 @@ export function TreeEditorLayout({
selectedNodeId={editingNodeId}
onNodeSelect={onNodeSelect}
onSelectAnswerType={onSelectAnswerType}
onNodeContextMenu={onNodeContextMenu}
/>
</div>

View File

@@ -0,0 +1,161 @@
import { useState, useCallback, useRef } from 'react'
import { editorAIApi } from '@/api/editorAI'
import type {
AIActionType,
EditorAIChatMessage,
AISuggestion,
ContextMenuPosition,
} from '@/types'
interface UseEditorAIOptions {
flowType: 'troubleshooting' | 'procedural'
treeId?: string | null
/** Returns the live flow structure from the editor for AI context */
getFlowContext?: () => Record<string, unknown> | null
/** Called when the AI response contains a working_tree update */
onFlowUpdate?: (workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => void
}
export function useEditorAI({ flowType, treeId, getFlowContext, onFlowUpdate }: UseEditorAIOptions) {
const [isOpen, setIsOpen] = useState(false)
const [focalNodeId, setFocalNodeId] = useState<string | null>(null)
const [contextMenu, setContextMenu] = useState<{
position: ContextMenuPosition
nodeId: string
} | null>(null)
const [sessionId, setSessionId] = useState<string | null>(null)
const [messages, setMessages] = useState<EditorAIChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [suggestions, setSuggestions] = useState<AISuggestion[]>([])
const pendingActionRef = useRef<AIActionType>('open_chat')
const ensureSession = useCallback(async () => {
if (sessionId) return sessionId
try {
const result = await editorAIApi.startSession(flowType, treeId || undefined)
setSessionId(result.session_id)
if (result.greeting) {
setMessages((prev) => [
...prev,
{
role: 'assistant' as const,
content: result.greeting,
timestamp: new Date().toISOString(),
},
])
}
return result.session_id
} catch {
return null
}
}, [sessionId, flowType, treeId])
const openPanel = useCallback((nodeId?: string, actionType?: AIActionType) => {
setIsOpen(true)
if (nodeId) setFocalNodeId(nodeId)
if (actionType) pendingActionRef.current = actionType
}, [])
const closePanel = useCallback(() => {
setIsOpen(false)
setContextMenu(null)
}, [])
const openContextMenu = useCallback((e: React.MouseEvent, nodeId: string) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ position: { x: e.clientX, y: e.clientY }, nodeId })
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
const sendMessage = useCallback(async () => {
if (!input.trim() || isLoading) return
const currentInput = input
const currentAction = pendingActionRef.current
const currentFocalNodeId = focalNodeId
const userMessage: EditorAIChatMessage = {
role: 'user',
content: currentInput,
timestamp: new Date().toISOString(),
action_type: currentAction,
}
setMessages((prev) => [...prev, userMessage])
setInput('')
setIsLoading(true)
try {
const sid = await ensureSession()
if (!sid) return
const result = await editorAIApi.sendMessage({
sessionId: sid,
content: currentInput,
actionType: currentAction,
focalNodeId: currentFocalNodeId,
flowContext: getFlowContext?.() || null,
})
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: result.content,
timestamp: new Date().toISOString(),
},
])
// Apply AI-generated flow structure to the editor
if (result.working_tree && onFlowUpdate) {
onFlowUpdate(result.working_tree, result.tree_metadata || null)
}
} catch {
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: 'Sorry, something went wrong. Please try again.',
timestamp: new Date().toISOString(),
},
])
} finally {
setIsLoading(false)
pendingActionRef.current = 'open_chat'
}
}, [input, isLoading, ensureSession, focalNodeId, getFlowContext, onFlowUpdate])
const triggerAction = useCallback(
(nodeId: string, actionType: AIActionType, prompt: string) => {
setFocalNodeId(nodeId)
pendingActionRef.current = actionType
setInput(prompt)
setIsOpen(true)
},
[]
)
return {
isOpen,
openPanel,
closePanel,
focalNodeId,
setFocalNodeId,
contextMenu,
openContextMenu,
closeContextMenu,
messages,
input,
setInput,
sendMessage,
isLoading,
suggestions,
setSuggestions,
triggerAction,
}
}

View File

@@ -162,6 +162,11 @@
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes fadeInRight {
from { transform: translateX(30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }

View File

@@ -0,0 +1,52 @@
import type { RFFlowFile, FlowExportData } from '@/types'
export class RFFlowParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'RFFlowParseError'
}
}
export function parseRFFlowFile(content: string): RFFlowFile {
const trimmed = content.trim()
if (!trimmed.startsWith('{')) {
throw new RFFlowParseError('Invalid file format. Expected JSON.')
}
try {
const data = JSON.parse(trimmed)
validateEnvelope(data)
return data as RFFlowFile
} catch (err) {
if (err instanceof RFFlowParseError) throw err
throw new RFFlowParseError(`Invalid JSON: ${(err as Error).message}`)
}
}
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
const obj = data as Record<string, unknown>
if (!obj.rfflow_version) {
throw new RFFlowParseError('Missing rfflow_version')
}
if (obj.rfflow_version !== '1.0') {
throw new RFFlowParseError(`Unsupported version: ${obj.rfflow_version}. Only 1.0 is supported.`)
}
const flow = obj.flow as Record<string, unknown> | undefined
if (!flow) {
throw new RFFlowParseError('Missing flow data')
}
if (!flow.name) {
throw new RFFlowParseError('Flow must have a name')
}
if (!flow.tree_structure) {
throw new RFFlowParseError('Flow must have a tree_structure')
}
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
}
}

View File

@@ -1,158 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAIChatStore } from '@/store/aiChatStore'
import { ChatPanel } from '@/components/ai-chat/ChatPanel'
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
import { Spinner } from '@/components/common/Spinner'
import { getTreeEditorPath } from '@/lib/routing'
import { toast } from '@/lib/toast'
import type { TreeStructure } from '@/types'
export function AIChatBuilderPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
const {
sessionId,
status,
currentPhase,
messages,
isResponding,
workingTree,
treeMetadata,
generatedTree,
isGenerating,
error,
startSession,
sendMessage,
generateTree,
importToEditor,
abandonSession,
resumeSession,
} = useAIChatStore()
// Start or resume session on mount
useEffect(() => {
const resumeId = searchParams.get('session')
if (resumeId && !sessionId) {
resumeSession(resumeId)
} else if (!sessionId && status === 'idle') {
startSession(flowType)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Store sessionId in URL for resume support
useEffect(() => {
if (sessionId && !searchParams.get('session')) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('session', sessionId)
return next
}, { replace: true })
}
}, [sessionId, searchParams, setSearchParams])
const handleSendMessage = useCallback(
(content: string) => {
sendMessage(content)
},
[sendMessage]
)
const [isSaving, setIsSaving] = useState(false)
const handleGenerate = useCallback(() => {
generateTree()
}, [generateTree])
const handleSave = useCallback(async () => {
if (isSaving) return
setIsSaving(true)
try {
const treeId = await importToEditor({
name: treeMetadata?.name,
description: treeMetadata?.description,
tags: treeMetadata?.tags,
})
const path = getTreeEditorPath(treeId, flowType)
navigate(path)
toast.success('Flow saved to library')
} catch {
toast.error('Failed to save flow')
} finally {
setIsSaving(false)
}
}, [isSaving, importToEditor, treeMetadata, flowType, navigate])
const handleReset = useCallback(async () => {
await abandonSession()
// Clear session from URL
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('session')
return next
}, { replace: true })
startSession(flowType)
}, [abandonSession, startSession, flowType, setSearchParams])
// Show error toast
useEffect(() => {
if (error) {
toast.error(error)
}
}, [error])
if (status === 'idle' && !sessionId) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
)
}
const previewTree = (generatedTree || workingTree) as TreeStructure | null
return (
<div className="flex h-full flex-col">
<ChatToolbar
currentPhase={currentPhase}
status={status}
isGenerating={isGenerating}
hasGeneratedTree={!!generatedTree}
isSaving={isSaving}
onGenerate={handleGenerate}
onSave={handleSave}
onReset={handleReset}
/>
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Chat (60%) */}
<div className="flex w-3/5 flex-col border-r border-border max-lg:w-full">
<ChatPanel
messages={messages}
isResponding={isResponding}
onSendMessage={handleSendMessage}
disabled={status !== 'active'}
/>
</div>
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
{previewTree ? (
<StaticTreePreview
tree={previewTree}
name={treeMetadata?.name}
/>
) : (
<EmptyPreview />
)}
</div>
</div>
</div>
)
}
export default AIChatBuilderPage

View File

@@ -233,39 +233,42 @@ export default function AssistantChatPage() {
{/* Input */}
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-end gap-3 max-w-3xl mx-auto">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about IT, networking, troubleshooting..."
rows={3}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<div className="flex flex-col gap-2">
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Send size={18} />
</button>
{messages.length >= 2 && (
<div className="max-w-3xl mx-auto">
<div className="flex items-end gap-3">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about IT, networking, troubleshooting..."
rows={3}
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<div className="flex flex-col gap-2">
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Flag size={18} />
<Send size={18} />
</button>
)}
{messages.length >= 2 && (
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
>
<Flag size={18} />
</button>
)}
</div>
</div>
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
</div>
</div>
</>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench, Sparkles } from 'lucide-react'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
@@ -12,8 +12,6 @@ import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/authStore'
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 {
@@ -35,18 +33,12 @@ export function MyTreesPage() {
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showCreateMenu, setShowCreateMenu] = useState(false)
const [showAIBuilder, setShowAIBuilder] = useState(false)
const [aiEnabled, setAiEnabled] = useState(false)
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
useEffect(() => {
loadMyTrees()
}, [user?.id])
useEffect(() => {
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
}, [])
const loadMyTrees = async () => {
if (!user?.id) return
setIsLoading(true)
@@ -178,25 +170,6 @@ export function MyTreesPage() {
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
</div>
</Link>
{aiEnabled && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
onClick={() => {
setShowCreateMenu(false)
setShowAIBuilder(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-4 w-4 text-primary" />
<div className="text-left">
<div className="font-medium">Flow Assist</div>
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
</div>
</button>
</>
)}
</div>
</>
)}
@@ -425,11 +398,6 @@ export function MyTreesPage() {
/>
)}
{/* AI Flow Builder Modal */}
<AIFlowBuilderModal
isOpen={showAIBuilder}
onClose={() => setShowAIBuilder(false)}
/>
</div>
)
}

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,8 +10,12 @@ 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'
import type { TreeType, MaintenanceSchedule, TargetList, ProceduralStep, IntakeFormField } from '@/types'
type SectionKey = 'details' | 'intake' | 'schedule'
@@ -42,8 +46,34 @@ export function ProceduralEditorPage() {
setIsSaving,
markSaved,
getTreeForSave,
replaceSteps,
} = useProceduralEditorStore()
const steps = useProceduralEditorStore(s => s.steps)
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => {
const stepsData = workingTree.steps as ProceduralStep[] | undefined
if (stepsData && Array.isArray(stepsData)) {
// Intake form may be in working_tree or in metadata
const intakeData = (workingTree.intake_form || metadata?.intake_form) as IntakeFormField[] | undefined
replaceSteps(stepsData, intakeData)
}
}, [replaceSteps])
const editorAI = useEditorAI({
flowType: 'procedural',
treeId: id,
getFlowContext: useCallback(() => {
return {
name,
description,
steps: steps as unknown as Record<string, unknown>[],
intake_form: intakeForm,
}
}, [steps, intakeForm, name, description]),
onFlowUpdate: handleFlowUpdate,
})
const isMaintenance = treeType === 'maintenance'
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
@@ -150,7 +180,9 @@ export function ProceduralEditorPage() {
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full overflow-hidden">
{/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Toolbar — sticky */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
@@ -175,6 +207,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 +317,55 @@ export function ProceduralEditorPage() {
)}
</div>
{/* Step List — flex-1, scrolls independently */}
{/* Step List */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<StepList />
<StepList onStepContextMenu={editorAI.openContextMenu} />
</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>{/* end main content column */}
<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>
)
}

View File

@@ -599,7 +599,7 @@ export function ProceduralNavigationPage() {
)}
</div>
{/* Right panel - step detail */}
{/* Right panel - step detail + copilot */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain p-4 sm:p-6">
{currentStep && (
<StepDetail
@@ -636,6 +636,17 @@ export function ProceduralNavigationPage() {
</div>
)}
</div>
{/* AI Copilot - in-flow panel */}
{treeId && copilotOpen && (
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={runtimeSteps[currentStepIndex]?.id}
/>
)}
</div>
{/* CSAT Modal */}
@@ -708,18 +719,9 @@ export function ProceduralNavigationPage() {
/>
)}
{/* AI Copilot */}
{/* AI Copilot toggle button */}
{treeId && (
<>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={runtimeSteps[currentStepIndex]?.id}
/>
</>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
)}
</div>
)

View File

@@ -17,7 +17,7 @@ import { TreeGridView } from '@/components/library/TreeGridView'
import { TreeListView } from '@/components/library/TreeListView'
import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@@ -67,7 +67,6 @@ export function QuickStartPage() {
const [showAllFavorites, setShowAllFavorites] = useState(false)
// AI Builder
const [showAIBuilder, setShowAIBuilder] = useState(false)
const { aiEnabled } = useCachedQuota()
// Tab state
@@ -454,7 +453,7 @@ export function QuickStartPage() {
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
/>
)}
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
@@ -481,7 +480,7 @@ export function QuickStartPage() {
{activeTab === 'mine' && canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create your first flow"
/>
)}
@@ -646,13 +645,6 @@ export function QuickStartPage() {
</div>
)}
{/* AI Builder Modal */}
{showAIBuilder && (
<AIFlowBuilderModal
isOpen={showAIBuilder}
onClose={() => setShowAIBuilder(false)}
/>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download, Sparkles } from 'lucide-react'
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
import { treesApi } from '@/api/trees'
import { treeMarkdownApi } from '@/api/treeMarkdown'
@@ -16,6 +16,11 @@ import { Spinner } from '@/components/common/Spinner'
import { cn, safeGetItem } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
import { ContextMenu } from '@/components/common/ContextMenu'
import { useEditorAI } from '@/hooks/useEditorAI'
import { findNodeInTree } from '@/store/treeEditorStore'
/** Recursively check if any node in the tree has type 'answer' */
function hasAnswerNodes(node: TreeStructure): boolean {
@@ -31,11 +36,13 @@ export function TreeEditorPage() {
const {
name,
description,
isDirty,
isLoading,
isSaving,
validationErrors,
editorMode,
treeStructure,
initNewTree,
loadTree,
loadDraft,
@@ -48,7 +55,10 @@ export function TreeEditorPage() {
setSaving,
selectNode,
updateNode,
deleteNode,
setEditorMode,
getAllNodeIds,
replaceTreeStructure,
} = useTreeEditorStore()
// Access undo/redo from temporal store
@@ -61,6 +71,8 @@ export function TreeEditorPage() {
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [isFixing, setIsFixing] = useState(false)
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
const [showExportModal, setShowExportModal] = useState(false)
const [importMetadata, setImportMetadata] = useState<Record<string, string | null> | null>(null)
// Mobile detection
const [isMobile, setIsMobile] = useState(false)
@@ -71,6 +83,38 @@ export function TreeEditorPage() {
return () => window.removeEventListener('resize', check)
}, [])
// AI Assist panel
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>) => {
// For troubleshooting flows, working_tree is the tree structure directly
if (workingTree.type && workingTree.id) {
replaceTreeStructure(workingTree as unknown as TreeStructure)
}
}, [replaceTreeStructure])
const editorAI = useEditorAI({
flowType: 'troubleshooting',
treeId: id,
getFlowContext: useCallback(() => {
if (!treeStructure) return null
return {
name,
description,
tree_structure: treeStructure as unknown as Record<string, unknown>,
}
}, [treeStructure, name, description]),
onFlowUpdate: handleFlowUpdate,
})
const previousEditingNodeRef = useRef<string | null>(null)
const handleAIPanelClose = useCallback(() => {
editorAI.closePanel()
if (previousEditingNodeRef.current) {
setEditingNodeId(previousEditingNodeRef.current)
previousEditingNodeRef.current = null
}
}, [editorAI])
// Calculate if there are blocking errors
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
@@ -166,6 +210,7 @@ export function TreeEditorPage() {
}
loadTree(tree)
setTreeStatus(tree.status) // Load status from existing tree
if (tree.import_metadata) setImportMetadata(tree.import_metadata)
} catch (err) {
console.error('Failed to load tree:', err)
toast.error('Failed to load flow')
@@ -477,7 +522,9 @@ export function TreeEditorPage() {
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full overflow-hidden">
{/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Draft Restore Prompt */}
{showDraftPrompt && (
@@ -687,6 +734,45 @@ export function TreeEditorPage() {
)}
{/* Validate */}
{isEditMode && (
<button
onClick={() => setShowExportModal(true)}
title="Export flow as .rfflow file"
className={cn(
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground'
)}
>
<Download className="h-4 w-4" />
Export
</button>
)}
{/* AI Assist toggle */}
<button
onClick={() => {
if (editorAI.isOpen) {
handleAIPanelClose()
} else {
if (editingNodeId) {
previousEditingNodeRef.current = editingNodeId
setEditingNodeId(null)
}
editorAI.openPanel()
}
}}
title="Toggle AI Assist panel"
className={cn(
'flex items-center gap-2 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'
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<Sparkles className="h-4 w-4" />
AI Assist
</button>
<button
onClick={handleManualValidate}
disabled={isSaving}
@@ -742,15 +828,29 @@ export function TreeEditorPage() {
</div>
)}
{/* Import provenance */}
{importMetadata && (
<div className="mx-4 mb-2 flex items-center gap-2 text-xs font-label text-muted-foreground">
<FileText className="h-3 w-3" />
<span>
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''}
{importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
</span>
</div>
)}
{/* Main Editor */}
<TreeEditorLayout
isMobile={isMobile}
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
editingNodeId={editingNodeId}
onNodeSelect={handleNodeSelect}
onSelectAnswerType={handleSelectAnswerType}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<TreeEditorLayout
isMobile={isMobile}
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
editingNodeId={editorAI.isOpen ? null : editingNodeId}
onNodeSelect={handleNodeSelect}
onSelectAnswerType={handleSelectAnswerType}
onNodeContextMenu={editorAI.openContextMenu}
/>
</div>
{/* Flow Analytics Panel (collapsible) */}
{showAnalytics && id && (
@@ -768,6 +868,87 @@ export function TreeEditorPage() {
onClose={handleCloseFixModal}
/>
)}
{/* Export Modal */}
{showExportModal && id && (
<ExportFlowModal
treeId={id}
treeName={name || 'flow'}
onClose={() => setShowExportModal(false)}
/>
)}
{/* AI Context Menu */}
{editorAI.contextMenu && (
<ContextMenu
position={editorAI.contextMenu.position}
items={[
{
id: 'generate-branch',
label: 'Generate Branch',
icon: <Sparkles className="h-4 w-4" />,
onClick: () => {
if (editingNodeId) {
previousEditingNodeRef.current = editingNodeId
setEditingNodeId(null)
}
editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'generate_branch',
`Generate a branch from this node`
)
},
},
{
id: 'explain',
label: 'Explain Node',
icon: <Sparkles className="h-4 w-4" />,
onClick: () => {
if (editingNodeId) {
previousEditingNodeRef.current = editingNodeId
setEditingNodeId(null)
}
editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'quick_action',
`Explain what this node does`
)
},
},
{
id: 'sep1',
label: '',
onClick: () => {},
separator: true,
},
{
id: 'delete',
label: 'Delete Node',
variant: 'danger' as const,
onClick: () => deleteNode(editorAI.contextMenu!.nodeId),
},
]}
onClose={editorAI.closeContextMenu}
/>
)}
</div>{/* end main content column */}
<EditorAIPanel
isOpen={editorAI.isOpen}
onClose={handleAIPanelClose}
focalNode={editorAI.focalNodeId && treeStructure
? findNodeInTree(editorAI.focalNodeId, treeStructure)
: null}
flowName={name}
flowType="troubleshooting"
nodeCount={treeStructure ? getAllNodeIds().length : 0}
messages={editorAI.messages}
input={editorAI.input}
onInputChange={editorAI.setInput}
onSend={editorAI.sendMessage}
isLoading={editorAI.isLoading}
suggestions={editorAI.suggestions}
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -8,6 +8,8 @@ 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 { ExportFlowModal } from '@/components/library/ExportFlowModal'
import { ImportFlowModal } from '@/components/library/ImportFlowModal'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { TreeGridView } from '@/components/library/TreeGridView'
import { TreeListView } from '@/components/library/TreeListView'
@@ -20,7 +22,6 @@ import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
import { useCachedQuota } from '@/hooks/useCachedQuota'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
@@ -76,8 +77,12 @@ export function TreeLibraryPage() {
// Fork modal state
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
// Import/Export modal state
const [showImportModal, setShowImportModal] = useState(false)
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(null)
// AI builder state
const [showAIBuilder, setShowAIBuilder] = useState(false)
const { aiEnabled } = useCachedQuota()
// Pin store
@@ -250,6 +255,11 @@ export function TreeLibraryPage() {
if (tree) setForkTarget(tree)
}
const handleExportTree = (treeId: string) => {
const tree = trees.find((t) => t.id === treeId)
if (tree) setExportTarget(tree)
}
const hasActiveFilters =
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
@@ -273,18 +283,16 @@ export function TreeLibraryPage() {
</div>
{canCreateTrees && (
<div className="flex items-center gap-2">
{aiEnabled && (
<button
onClick={() => navigate('/ai/chat')}
className="flex 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 transition-opacity"
>
<Sparkles className="h-4 w-4" />
Flow Assist
</button>
)}
<button
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
>
<FileUp className="h-4 w-4" />
Import
</button>
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create New"
/>
</div>
@@ -493,6 +501,7 @@ export function TreeLibraryPage() {
setShowDeleteConfirm(true)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
@@ -509,6 +518,7 @@ export function TreeLibraryPage() {
setShowDeleteConfirm(true)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
@@ -530,6 +540,7 @@ export function TreeLibraryPage() {
)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
pinnedTreeIds={pinnedTreeIds}
onTogglePin={togglePin}
pinLoadingTreeIds={pinLoadingTreeIds}
@@ -567,13 +578,6 @@ export function TreeLibraryPage() {
isLoading={isDeleting}
/>
{/* AI Builder Modal */}
{showAIBuilder && (
<AIFlowBuilderModal
isOpen={showAIBuilder}
onClose={() => setShowAIBuilder(false)}
/>
)}
{forkTarget && (
<ForkModal
@@ -582,6 +586,20 @@ export function TreeLibraryPage() {
onClose={() => setForkTarget(null)}
/>
)}
{exportTarget && (
<ExportFlowModal
treeId={exportTarget.id}
treeName={exportTarget.name}
onClose={() => setExportTarget(null)}
/>
)}
{showImportModal && (
<ImportFlowModal
onClose={() => { setShowImportModal(false); loadTrees() }}
/>
)}
</div>
)
}

View File

@@ -626,9 +626,9 @@ export function TreeNavigationPage() {
}
return (
<div className="h-full">
<div className="h-full flex overflow-hidden">
{/* Main Content */}
<div className={cn('h-full overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
<div className={cn('h-full flex-1 min-w-0 overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
<div className="mx-auto max-w-4xl">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
@@ -1264,6 +1264,17 @@ export function TreeNavigationPage() {
</div>
</div>
{/* AI Copilot - in-flow panel */}
{treeId && copilotOpen && (
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={currentNodeId}
/>
)}
{/* Scratchpad Sidebar */}
{session && (
<ScratchpadSidebar
@@ -1274,18 +1285,9 @@ export function TreeNavigationPage() {
/>
)}
{/* AI Copilot */}
{/* AI Copilot toggle button */}
{treeId && (
<>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
<CopilotPanel
isOpen={copilotOpen}
onClose={() => setCopilotOpen(false)}
treeId={treeId}
sessionId={session?.id}
currentNodeId={currentNodeId}
/>
</>
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
)}
</div>
)

View File

@@ -36,7 +36,6 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
@@ -291,14 +290,6 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'ai/chat',
element: (
<Suspense fallback={<PageLoader />}>
<AIChatBuilderPage />
</Suspense>
),
},
{
path: 'assistant',
element: (

View File

@@ -1,196 +0,0 @@
import { create } from 'zustand'
import { AxiosError } from 'axios'
import { aiChatApi } from '@/api/aiChat'
import type {
ChatMessage,
InterviewPhase,
TreeStructure,
} from '@/types'
interface TreeMetadata {
name?: string
description?: string
tags?: string[]
category_id?: string
}
interface AIChatState {
// Session
sessionId: string | null
status: 'idle' | 'active' | 'completed' | 'abandoned'
currentPhase: InterviewPhase
flowType: 'troubleshooting' | 'procedural' | null
// Conversation
messages: ChatMessage[]
isResponding: boolean
// Progressive tree
workingTree: TreeStructure | null
treeMetadata: TreeMetadata | null
// Final generation
generatedTree: TreeStructure | null
isGenerating: boolean
importedTreeId: string | null
// Error
error: string | null
// Actions
startSession: (flowType: 'troubleshooting' | 'procedural') => Promise<void>
sendMessage: (content: string) => Promise<void>
generateTree: () => Promise<void>
importToEditor: (params?: { name?: string; description?: string; category_id?: string; tags?: string[] }) => Promise<string>
abandonSession: () => Promise<void>
resumeSession: (sessionId: string) => Promise<void>
reset: () => void
}
const initialState = {
sessionId: null,
status: 'idle' as const,
currentPhase: 'scoping' as InterviewPhase,
flowType: null,
messages: [],
isResponding: false,
workingTree: null,
treeMetadata: null,
generatedTree: null,
isGenerating: false,
importedTreeId: null,
error: null,
}
function extractErrorMessage(e: unknown, fallback: string): string {
if (e instanceof AxiosError && e.response?.data?.detail) {
const detail = e.response.data.detail
return typeof detail === 'string' ? detail : detail.message || fallback
}
if (e instanceof Error) return e.message
return fallback
}
export const useAIChatStore = create<AIChatState>((set, get) => ({
...initialState,
startSession: async (flowType) => {
set({ ...initialState, status: 'active', flowType, isResponding: true, error: null })
try {
const response = await aiChatApi.startSession(flowType)
set({
sessionId: response.session_id,
currentPhase: response.current_phase,
messages: [{
role: 'assistant',
content: response.greeting,
timestamp: new Date().toISOString(),
}],
isResponding: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to start session'), isResponding: false, status: 'idle' })
}
},
sendMessage: async (content) => {
const { sessionId, messages, isResponding } = get()
if (!sessionId || isResponding) return
const userMessage: ChatMessage = {
role: 'user',
content,
timestamp: new Date().toISOString(),
}
set({
messages: [...messages, userMessage],
isResponding: true,
error: null,
})
try {
const response = await aiChatApi.sendMessage(sessionId, content)
const aiMessage: ChatMessage = {
role: 'assistant',
content: response.content,
timestamp: new Date().toISOString(),
}
set((state) => ({
messages: [...state.messages, aiMessage],
currentPhase: response.current_phase,
workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
isResponding: false,
}))
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to send message'), isResponding: false })
}
},
generateTree: async () => {
const { sessionId, isGenerating } = get()
if (!sessionId || isGenerating) return
set({ isGenerating: true, error: null })
try {
const response = await aiChatApi.generateTree(sessionId)
set({
generatedTree: response.tree_structure as unknown as TreeStructure,
workingTree: response.tree_structure as unknown as TreeStructure,
treeMetadata: response.tree_metadata as TreeMetadata,
status: 'completed',
isGenerating: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to generate tree'), isGenerating: false })
}
},
importToEditor: async (params) => {
const { sessionId } = get()
if (!sessionId) throw new Error('No active session')
const response = await aiChatApi.importTree(sessionId, params)
set({ importedTreeId: response.tree_id })
return response.tree_id
},
abandonSession: async () => {
const { sessionId } = get()
if (!sessionId) return
try {
await aiChatApi.abandonSession(sessionId)
} catch {
// Best effort — session may have already expired
}
set({ ...initialState })
},
resumeSession: async (sessionId) => {
set({ isResponding: true, error: null })
try {
const session = await aiChatApi.getSession(sessionId)
set({
sessionId: session.session_id,
status: session.status === 'active' ? 'active' : (session.status as 'completed' | 'abandoned'),
currentPhase: session.current_phase,
flowType: session.flow_type,
messages: session.conversation_history as ChatMessage[],
workingTree: session.working_tree as TreeStructure | null,
treeMetadata: session.tree_metadata as TreeMetadata | null,
generatedTree: session.generated_tree as TreeStructure | null,
isResponding: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to resume session'), isResponding: false })
}
},
reset: () => set({ ...initialState }),
}))

View File

@@ -1,237 +0,0 @@
import { create } from 'zustand'
import { aiBuilderApi } from '@/api/aiBuilder'
import type { AIQuotaStatus, AIBranch, AIAssembleResponse, AIWizardPhase } from '@/types'
interface AIFlowBuilderState {
// Wizard state
phase: AIWizardPhase
conversationId: string | null
metadata: {
flow_type: 'troubleshooting' | 'procedural'
name: string
description: string
environment_tags: string[]
category_id: string | null
}
// Stage 2
suggestedBranches: AIBranch[]
selectedBranches: AIBranch[]
// Stage 3
currentBranchIndex: number
// Stage 4
assembledTree: AIAssembleResponse | null
// Quota
quota: AIQuotaStatus | null
// UI state
isLoading: boolean
isGeneratingAll: boolean
stopGeneratingAll: boolean
error: string | null
// Actions
loadQuota: () => Promise<void>
setMetadata: (metadata: Partial<AIFlowBuilderState['metadata']>) => void
start: () => Promise<void>
scaffold: () => Promise<void>
selectBranches: (branches: AIBranch[]) => void
generateBranchDetail: (branchName: string) => Promise<void>
assemble: () => Promise<void>
generateAllBranchDetails: () => Promise<void>
cancelGenerateAll: () => void
reset: () => void
setPhase: (phase: AIWizardPhase) => void
setError: (error: string | null) => void
}
const initialMetadata = {
flow_type: 'troubleshooting' as const,
name: '',
description: '',
environment_tags: [] as string[],
category_id: null as string | null,
}
export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) => ({
phase: 'foundation',
conversationId: null,
metadata: { ...initialMetadata },
suggestedBranches: [],
selectedBranches: [],
currentBranchIndex: 0,
assembledTree: null,
quota: null,
isLoading: false,
isGeneratingAll: false,
stopGeneratingAll: false,
error: null,
loadQuota: async () => {
try {
const quota = await aiBuilderApi.getQuota()
set({ quota })
} catch {
// Silently fail — quota display is optional
}
},
setMetadata: (metadata) => {
set((state) => ({
metadata: { ...state.metadata, ...metadata },
}))
},
start: async () => {
const { metadata } = get()
set({ isLoading: true, error: null })
try {
const response = await aiBuilderApi.start({
flow_type: metadata.flow_type,
name: metadata.name,
description: metadata.description,
environment_tags: metadata.environment_tags,
category_id: metadata.category_id ?? undefined,
})
set({
conversationId: response.conversation_id,
phase: 'scaffolding',
isLoading: false,
})
} catch (err) {
const message = _extractError(err)
set({ error: message, isLoading: false })
}
},
scaffold: async () => {
const { conversationId } = get()
if (!conversationId) return
set({ isLoading: true, error: null, phase: 'generating' })
try {
const response = await aiBuilderApi.scaffold(conversationId)
const branches: AIBranch[] = response.branches.map((b) => ({
name: b.name,
description: b.description,
}))
set({
suggestedBranches: branches,
selectedBranches: branches,
phase: 'scaffolding',
isLoading: false,
})
} catch (err) {
const message = _extractError(err)
set({ error: message, phase: 'error', isLoading: false })
}
},
selectBranches: (branches) => {
set({ selectedBranches: branches })
},
generateBranchDetail: async (branchName) => {
const { conversationId, selectedBranches } = get()
if (!conversationId) return
set({ isLoading: true, error: null, phase: 'generating' })
try {
const response = await aiBuilderApi.branchDetail(conversationId, branchName)
const updatedBranches = selectedBranches.map((b) =>
b.name === branchName ? { ...b, steps: response.steps } : b
)
// Advance to the next branch that still needs detail
const nextIndex = updatedBranches.findIndex((b) => !b.steps)
const currentBranchIndex = nextIndex !== -1 ? nextIndex : updatedBranches.findIndex((b) => b.name === branchName)
set({
selectedBranches: updatedBranches,
currentBranchIndex,
phase: 'detailing',
isLoading: false,
})
} catch (err) {
const message = _extractError(err)
set({ error: message, phase: 'error', isLoading: false })
}
},
assemble: async () => {
const { conversationId, selectedBranches } = get()
if (!conversationId) return
set({ isLoading: true, error: null })
try {
const response = await aiBuilderApi.assemble(
conversationId,
selectedBranches.map((b) => ({
name: b.name,
description: b.description,
steps: b.steps,
}))
)
set({
assembledTree: response,
phase: 'reviewing',
isLoading: false,
})
} catch (err) {
const message = _extractError(err)
set({ error: message, phase: 'error', isLoading: false })
}
},
generateAllBranchDetails: async () => {
const { selectedBranches, generateBranchDetail } = get()
const undetailed = selectedBranches.filter((b) => !b.steps)
if (undetailed.length === 0) return
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
for (const branch of undetailed) {
if (get().stopGeneratingAll) break
// Set currentBranchIndex so tabs show the active branch
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
if (idx !== -1) set({ currentBranchIndex: idx })
await generateBranchDetail(branch.name)
// If generateBranchDetail set phase to 'error', stop
if (get().phase === 'error') break
}
set({ isGeneratingAll: false })
},
cancelGenerateAll: () => {
set({ stopGeneratingAll: true })
},
reset: () => {
set({
phase: 'foundation',
conversationId: null,
metadata: { ...initialMetadata },
suggestedBranches: [],
selectedBranches: [],
currentBranchIndex: 0,
assembledTree: null,
isLoading: false,
isGeneratingAll: false,
stopGeneratingAll: false,
error: null,
})
},
setPhase: (phase) => set({ phase }),
setError: (error) => set({ error }),
}))
function _extractError(err: unknown): string {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string | { message?: string } } } }
const detail = axiosErr.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object' && 'message' in detail) return detail.message ?? 'Unknown error'
}
if (err instanceof Error) return err.message
return 'An unexpected error occurred'
}

View File

@@ -112,6 +112,9 @@ interface ProceduralEditorState {
updateField: (index: number, updates: Partial<IntakeFormField>) => void
moveField: (fromIndex: number, toIndex: number) => void
// Actions - AI Integration
replaceSteps: (steps: ProceduralStep[], intakeForm?: IntakeFormField[]) => void
// Actions - Save
setIsSaving: (saving: boolean) => void
markSaved: () => void
@@ -341,6 +344,15 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
})
},
// AI Integration
replaceSteps: (steps, intakeForm) => {
set((state) => {
state.steps = steps
if (intakeForm) state.intakeForm = intakeForm
state.isDirty = true
})
},
// Save
setIsSaving: (saving) => set((state) => { state.isSaving = saving }),
markSaved: () => set((state) => { state.isDirty = false }),

View File

@@ -283,6 +283,9 @@ interface TreeEditorState {
syncMarkdownToTree: () => void
syncTreeToMarkdown: () => void
// Actions - AI Integration
replaceTreeStructure: (structure: TreeStructure) => void
// Actions - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
@@ -855,7 +858,7 @@ export const useTreeEditorStore = create<TreeEditorState>()(
// Check for orphaned nodes (not root and not referenced)
allNodeIds.forEach(id => {
if (id !== 'root' && !referencedIds.has(id)) {
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
// Check if it's a direct child of another node (via children array)
let isChildOfAny = false
const checkIfChild = (node: TreeStructure) => {
@@ -1015,6 +1018,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
}
},
// AI Integration
replaceTreeStructure: (structure: TreeStructure) => {
set((state) => {
state.treeStructure = structure
state.selectedNodeId = structure.id
state.isDirty = true
})
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},

View File

@@ -1,43 +0,0 @@
export type InterviewPhase = 'scoping' | 'discovery' | 'enrichment' | 'review' | 'generation'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
timestamp: string
}
export interface AIChatStartResponse {
session_id: string
greeting: string
current_phase: InterviewPhase
}
export interface AIChatMessageResponse {
content: string
current_phase: InterviewPhase
working_tree: Record<string, unknown> | null
tree_metadata: Record<string, unknown> | null
}
export interface AIChatSessionResponse {
session_id: string
status: 'active' | 'completed' | 'abandoned'
current_phase: InterviewPhase
flow_type: 'troubleshooting' | 'procedural'
conversation_history: ChatMessage[]
working_tree: Record<string, unknown> | null
tree_metadata: Record<string, unknown> | null
message_count: number
generated_tree: Record<string, unknown> | null
}
export interface AIChatGenerateResponse {
tree_structure: Record<string, unknown>
tree_metadata: Record<string, unknown>
status: string
}
export interface AIChatImportResponse {
tree_id: string
tree_type: string
}

View File

@@ -0,0 +1,62 @@
export type AIActionType =
| 'generate_full'
| 'generate_branch'
| 'modify_node'
| 'add_steps'
| 'quick_action'
| 'open_chat'
| 'variable_inference'
export interface AIDelta {
action: 'add' | 'modify' | 'delete'
target_node_id: string
nodes: Record<string, unknown>[]
explanation: string
}
export interface AISuggestion {
id: string
action_type: AIActionType
target_node_id: string | null
changes_json: {
before?: Record<string, unknown>
after?: Record<string, unknown>
delta?: AIDelta
}
status: 'pending' | 'accepted' | 'dismissed'
created_at: string
resolved_at: string | null
}
export interface EditorAIChatMessage {
role: 'user' | 'assistant'
content: string
timestamp: string
action_type?: AIActionType
delta?: AIDelta
knowledge?: KnowledgeCitation[]
}
export interface KnowledgeCitation {
title: string
url: string
excerpt: string
}
export interface ContextMenuAction {
id: string
label: string
icon: string
action_type: AIActionType
description?: string
}
export interface ContextMenuPosition {
x: number
y: number
}
export interface SuggestionMarker {
_suggestion?: true
_suggestion_id?: string
}

View File

@@ -0,0 +1,33 @@
export interface FlowExportCategory {
name: string
slug: string
}
export interface FlowExportData {
name: string
description: string | null
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
version: number
author_name: string | null
category: FlowExportCategory | null
tags: string[]
tree_structure: Record<string, unknown>
intake_form: Record<string, unknown>[] | null
}
export interface RFFlowFile {
rfflow_version: string
exported_at: string
source_app: string
flow: FlowExportData
}
export interface FlowImportResponse {
tree_id: string
name: string
tree_type: string
status: string
category_created: boolean
tags_created: string[]
validation_warnings: string[]
}

View File

@@ -55,12 +55,21 @@ export type {
AIFixValidationError,
} from './ai-fix'
export type {
InterviewPhase,
ChatMessage,
AIChatStartResponse,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportResponse,
} from './ai-chat'
RFFlowFile,
FlowExportData,
FlowExportCategory,
FlowImportResponse,
} from './flowTransfer'
export type {
AIActionType,
AIDelta,
AISuggestion,
EditorAIChatMessage,
KnowledgeCitation,
ContextMenuAction,
ContextMenuPosition,
SuggestionMarker,
} from './editor-ai'

View File

@@ -176,6 +176,12 @@ export interface Tree {
updated_at: string
usage_count: number
fork_info: ForkInfo | null
import_metadata: {
original_author_name?: string | null
exported_at?: string
imported_at?: string
source_app?: string
} | null
}
export interface TreeListItem {