* 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>
79 KiB
Editor-Embedded Flow Assist - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace the standalone /ai/chat page with context-aware AI side panels embedded in the Troubleshooting and Procedural editors, supporting targeted actions, ghost node suggestions, and config-driven model routing.
Architecture: Backend extends existing AI chat service with action-type dispatch, delta responses, and model tier routing. Frontend adds a shared EditorAIPanel component, ContextMenu component, and useEditorAI hook that integrate into both editors. Ghost nodes use a _suggestion flag in tree/step structures with zundo pause/resume for clean undo history.
Tech Stack: FastAPI, SQLAlchemy, Alembic, React 19, Zustand (zundo), Tailwind CSS, Anthropic/Google AI SDK
Design doc: docs/plans/2026-03-06-editor-embedded-flow-assist-design.md
Phase 1: Bug Fix + Backend Foundation
Task 1: Fix Orphan Validation False Positive
Files:
- Modify:
frontend/src/store/treeEditorStore.ts:858
Step 1: Locate and fix the bug
In frontend/src/store/treeEditorStore.ts, line 858, change:
// BEFORE (line 858)
if (id !== 'root' && !referencedIds.has(id)) {
// AFTER
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
The root node's ID is not always 'root' — AI-generated trees use descriptive IDs like "verify-account-exists". The root is never referenced by next_node_id so it gets flagged as orphaned.
Step 2: Verify the fix
Run: cd frontend && npm run build
Expected: Build succeeds with no type errors.
Step 3: Commit
git add frontend/src/store/treeEditorStore.ts
git commit -m "fix: use actual root node ID in orphan validation check"
Task 2: Add Model Tier Configuration
Files:
- Modify:
backend/app/core/config.py:77-85
Step 1: Write the failing test
Create test in backend/tests/test_config_model_tiers.py:
"""Tests for AI model tier configuration."""
from app.core.config import settings
def test_ai_model_tiers_exist():
"""Model tier config has fast and standard entries."""
assert "fast" in settings.AI_MODEL_TIERS
assert "standard" in settings.AI_MODEL_TIERS
def test_action_model_map_covers_all_actions():
"""Every action type maps to a valid tier."""
valid_tiers = set(settings.AI_MODEL_TIERS.keys())
for action, tier in settings.ACTION_MODEL_MAP.items():
assert tier in valid_tiers, f"Action '{action}' maps to unknown tier '{tier}'"
def test_get_model_for_action():
"""get_model_for_action resolves tier to model name."""
model = settings.get_model_for_action("generate_full")
assert isinstance(model, str)
assert len(model) > 0
def test_get_model_for_action_unknown_falls_back():
"""Unknown action types fall back to standard tier."""
model = settings.get_model_for_action("nonexistent_action")
assert model == settings.AI_MODEL_TIERS["standard"]
Step 2: Run test to verify it fails
Run: cd backend && python -m pytest tests/test_config_model_tiers.py -v
Expected: FAIL — AI_MODEL_TIERS attribute does not exist.
Step 3: Add model tier config to Settings class
In backend/app/core/config.py, after the existing AI config lines (~line 85), add:
# Model tier routing
AI_MODEL_TIERS: dict[str, str] = {
"fast": "claude-haiku-4-5-20251001",
"standard": "claude-sonnet-4-6-20250514",
}
ACTION_MODEL_MAP: dict[str, str] = {
"generate_full": "standard",
"generate_branch": "standard",
"modify_node": "fast",
"add_steps": "standard",
"quick_action": "fast",
"open_chat": "standard",
"variable_inference": "fast",
}
def get_model_for_action(self, action_type: str) -> str:
"""Resolve an action type to a concrete model name."""
tier = self.ACTION_MODEL_MAP.get(action_type, "standard")
return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"])
Step 4: Run test to verify it passes
Run: cd backend && python -m pytest tests/test_config_model_tiers.py -v
Expected: All 4 tests PASS.
Step 5: Commit
git add backend/app/core/config.py backend/tests/test_config_model_tiers.py
git commit -m "feat: add config-driven AI model tier routing"
Task 3: Extend AI Chat Session Model
Files:
- Modify:
backend/app/models/ai_chat_session.py - Create:
backend/alembic/versions/051_extend_ai_chat_session.py
Step 1: Add columns to AIChatSession model
In backend/app/models/ai_chat_session.py, add after the existing column definitions:
# Editor-embedded session: links to a specific tree/flow
tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
archived_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
Add the relationship (if Tree model import is needed, add it):
tree: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[tree_id])
Step 2: Create the Alembic migration
Run: cd backend && alembic revision -m "extend ai chat session with tree_id and archived_at" --rev-id=051
Then edit the generated file backend/alembic/versions/051_extend_ai_chat_session.py:
"""extend ai chat session with tree_id and archived_at
Revision ID: 051
"""
from alembic import op
import sqlalchemy as sa
revision = "051"
down_revision = "050" # Verify this matches the actual previous migration
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ai_chat_sessions",
sa.Column("tree_id", sa.UUID(), nullable=True),
)
op.add_column(
"ai_chat_sessions",
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_ai_chat_sessions_tree_id", "ai_chat_sessions", ["tree_id"])
op.create_foreign_key(
"fk_ai_chat_sessions_tree_id",
"ai_chat_sessions",
"trees",
["tree_id"],
["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
op.drop_constraint("fk_ai_chat_sessions_tree_id", "ai_chat_sessions", type_="foreignkey")
op.drop_index("ix_ai_chat_sessions_tree_id", table_name="ai_chat_sessions")
op.drop_column("ai_chat_sessions", "archived_at")
op.drop_column("ai_chat_sessions", "tree_id")
Step 3: Run migration
Run: cd backend && alembic upgrade head
Expected: Migration applies successfully.
Step 4: Verify with psql
Run: docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_chat_sessions" | grep -E "tree_id|archived_at"
Expected: Both columns visible.
Step 5: Commit
git add backend/app/models/ai_chat_session.py backend/alembic/versions/051_extend_ai_chat_session.py
git commit -m "feat: extend AI chat session with tree_id and archived_at"
Task 4: Create AI Suggestion Model + Migration
Files:
- Create:
backend/app/models/ai_suggestion.py - Create:
backend/alembic/versions/052_add_ai_suggestion_table.py - Modify:
backend/alembic/env.py(import new model)
Step 1: Create the model
Create backend/app/models/ai_suggestion.py:
"""AI Suggestion model for tracking AI-applied changes to flows."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AISuggestion(Base):
__tablename__ = "ai_suggestions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"),
nullable=True,
)
action_type: Mapped[str] = mapped_column(
String(50), nullable=False
)
target_node_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
changes_json: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
) # pending, accepted, dismissed
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Relationships
tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id])
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
session: Mapped["AIChatSession"] = relationship(
"AIChatSession", foreign_keys=[session_id]
)
Step 2: Import in alembic/env.py
In backend/alembic/env.py, add with the other model imports:
from app.models.ai_suggestion import AISuggestion # noqa: F401
Step 3: Create migration manually
Create backend/alembic/versions/052_add_ai_suggestion_table.py:
"""add ai suggestion table
Revision ID: 052
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "052"
down_revision = "051"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"ai_suggestions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), nullable=True),
sa.Column("action_type", sa.String(50), nullable=False),
sa.Column("target_node_id", sa.String(255), nullable=True),
sa.Column("changes_json", JSONB, nullable=False, server_default="{}"),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_ai_suggestions_tree_id", "ai_suggestions", ["tree_id"])
op.create_index("ix_ai_suggestions_user_id", "ai_suggestions", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_ai_suggestions_user_id", table_name="ai_suggestions")
op.drop_index("ix_ai_suggestions_tree_id", table_name="ai_suggestions")
op.drop_table("ai_suggestions")
Step 4: Run migration
Run: cd backend && alembic upgrade head
Expected: Table created successfully.
Step 5: Verify
Run: docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_suggestions"
Expected: Table schema displayed with all columns.
Step 6: Commit
git add backend/app/models/ai_suggestion.py backend/alembic/versions/052_add_ai_suggestion_table.py backend/alembic/env.py
git commit -m "feat: add AI suggestion audit trail table"
Task 5: Add Action Type to AI Chat Schemas + Endpoints
Files:
- Modify:
backend/app/schemas/ai_chat.py - Modify:
backend/app/api/endpoints/ai_chat.py
Step 1: Write the failing test
Add to backend/tests/test_ai_chat.py (or create new file backend/tests/test_ai_chat_action_types.py):
"""Tests for action-type routing in AI chat endpoints."""
import pytest
from unittest.mock import AsyncMock, PropertyMock, patch
@pytest.fixture
def _enable_ai(monkeypatch):
"""Enable AI for tests without API key."""
from app.core.config import Settings
monkeypatch.setattr(Settings, "ai_enabled", PropertyMock(return_value=True))
@pytest.mark.asyncio
async def test_send_message_with_action_type(client, auth_headers, _enable_ai):
"""Messages can include an action_type parameter."""
# Start a session first
with patch("app.api.endpoints.ai_chat.ai_chat_service") as mock_svc:
mock_svc.start_chat_session = AsyncMock(return_value={
"session_id": "test-123",
"greeting": "Hello",
"current_phase": "scoping",
})
start_resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
assert start_resp.status_code == 200
# Send message with action_type
mock_svc.send_message = AsyncMock(return_value={
"content": "Here's a branch",
"current_phase": "enrichment",
"working_tree": None,
"tree_metadata": None,
})
resp = await client.post(
f"/api/v1/ai/chat/sessions/test-123/messages",
json={
"content": "Generate a branch from this node",
"action_type": "generate_branch",
"focal_node_id": "check-connectivity",
},
headers=auth_headers,
)
assert resp.status_code == 200
Step 2: Run test to verify it fails
Run: cd backend && python -m pytest tests/test_ai_chat_action_types.py -v
Expected: FAIL — action_type not accepted in schema.
Step 3: Update schemas
In backend/app/schemas/ai_chat.py, update AIChatMessageRequest:
from typing import Literal, Optional
VALID_ACTION_TYPES = Literal[
"generate_full",
"generate_branch",
"modify_node",
"add_steps",
"quick_action",
"open_chat",
"variable_inference",
]
class AIChatMessageRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=5000)
action_type: Optional[VALID_ACTION_TYPES] = Field(
default="open_chat",
description="Type of AI action to perform, determines model tier and prompt",
)
focal_node_id: Optional[str] = Field(
default=None,
description="ID of the node/step being acted on (for targeted actions)",
)
Also update AIChatStartRequest to accept optional tree_id:
class AIChatStartRequest(BaseModel):
flow_type: Literal["troubleshooting", "procedural"]
tree_id: Optional[str] = Field(
default=None,
description="ID of existing tree to attach this session to (editor-embedded mode)",
)
Step 4: Update endpoint to pass action_type through
In backend/app/api/endpoints/ai_chat.py, update the send_message endpoint to pass action_type and focal_node_id to the service:
# In the send_message endpoint, after extracting request data:
result = await ai_chat_service.send_message(
session_id=session_id,
user_message=data.content,
user_id=str(current_user.id),
db=db,
action_type=data.action_type,
focal_node_id=data.focal_node_id,
)
In the start_session endpoint, pass tree_id if provided:
# When creating session, pass tree_id
result = await ai_chat_service.start_chat_session(
user_id=str(current_user.id),
flow_type=data.flow_type,
db=db,
account_id=str(current_user.account_id) if current_user.account_id else None,
tree_id=data.tree_id,
)
Step 5: Update ai_chat_service.send_message signature
In backend/app/core/ai_chat_service.py, update send_message to accept the new params (add action_type: str = "open_chat" and focal_node_id: str | None = None to the signature). For now, just accept them — prompt dispatch will come in a later task.
Also update start_chat_session to accept and store tree_id:
async def start_chat_session(
self,
user_id: str,
flow_type: str,
db: AsyncSession,
account_id: str | None = None,
tree_id: str | None = None,
) -> dict:
# ... existing code ...
session = AIChatSession(
# ... existing fields ...
tree_id=uuid.UUID(tree_id) if tree_id else None,
)
Step 6: Run tests
Run: cd backend && python -m pytest tests/test_ai_chat_action_types.py -v
Expected: PASS.
Run: cd backend && python -m pytest tests/test_ai_chat.py -v
Expected: Existing tests still PASS.
Step 7: Commit
git add backend/app/schemas/ai_chat.py backend/app/api/endpoints/ai_chat.py backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py
git commit -m "feat: add action_type and focal_node_id to AI chat message API"
Task 6: Add Model Tier Routing to AI Chat Service
Files:
- Modify:
backend/app/core/ai_chat_service.py
Step 1: Write the failing test
Add to backend/tests/test_ai_chat_action_types.py:
def test_model_routing_uses_action_type():
"""Service selects model based on action_type."""
from app.core.config import settings
# Fast actions should use fast model
fast_model = settings.get_model_for_action("quick_action")
assert fast_model == settings.AI_MODEL_TIERS["fast"]
# Standard actions should use standard model
standard_model = settings.get_model_for_action("generate_branch")
assert standard_model == settings.AI_MODEL_TIERS["standard"]
Step 2: Run test — should pass (config already done in Task 2)
Run: cd backend && python -m pytest tests/test_ai_chat_action_types.py::test_model_routing_uses_action_type -v
Expected: PASS (config was added in Task 2).
Step 3: Update AI chat service to use model routing
In backend/app/core/ai_chat_service.py, find where the AI model is selected (look for settings.AI_MODEL or settings.AI_MODEL_ANTHROPIC or settings.AI_MODEL_GEMINI). Replace the hardcoded model selection with:
# Instead of:
# model = settings.AI_MODEL_ANTHROPIC (or similar)
# Use:
model = settings.get_model_for_action(action_type)
This applies to both send_message and generate_final_tree methods. The generate_final_tree method should use action_type="generate_full".
Important: The service may use either Anthropic or Gemini provider. The model tier config currently has Anthropic model names. If using Gemini provider, fall back to settings.AI_MODEL_GEMINI. Add a provider check:
def _get_model(self, action_type: str) -> str:
"""Get the model name for this action, respecting provider."""
if settings.AI_PROVIDER == "gemini":
return settings.AI_MODEL_GEMINI
return settings.get_model_for_action(action_type)
Step 4: Run full AI chat tests
Run: cd backend && python -m pytest tests/test_ai_chat.py tests/test_ai_chat_action_types.py -v
Expected: All PASS.
Step 5: Commit
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py
git commit -m "feat: route AI model selection through action-type config"
Phase 2: Core Frontend Infrastructure
Task 7: Create Frontend Types for Editor AI
Files:
- Create:
frontend/src/types/editor-ai.ts - Modify:
frontend/src/types/index.ts
Step 1: Create the types file
Create frontend/src/types/editor-ai.ts:
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 EditorAISessionResponse {
session_id: string
status: 'active' | 'completed' | 'archived'
flow_type: 'troubleshooting' | 'procedural'
conversation_history: EditorAIChatMessage[]
tree_id: string | null
message_count: number
}
export interface ContextMenuAction {
id: string
label: string
icon: string // Lucide icon name
action_type: AIActionType
description?: string
}
export interface ContextMenuPosition {
x: number
y: number
}
/** Ghost node/step marker — mixed into TreeStructure or ProceduralStep */
export interface SuggestionMarker {
_suggestion?: true
_suggestion_id?: string // links to AISuggestion.id
}
Step 2: Export from types/index.ts
Add to frontend/src/types/index.ts:
export type {
AIActionType,
AIDelta,
AISuggestion,
EditorAIChatMessage,
KnowledgeCitation,
EditorAISessionResponse,
ContextMenuAction,
ContextMenuPosition,
SuggestionMarker,
} from './editor-ai'
Step 3: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 4: Commit
git add frontend/src/types/editor-ai.ts frontend/src/types/index.ts
git commit -m "feat: add TypeScript types for editor-embedded AI"
Task 8: Create ContextMenu Component
Files:
- Create:
frontend/src/components/common/ContextMenu.tsx
Step 1: Create the component
Create frontend/src/components/common/ContextMenu.tsx:
import { useEffect, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import type { ContextMenuPosition } from '@/types'
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
const adjustedStyle = (() => {
const style: React.CSSProperties = {
position: 'fixed',
zIndex: 100,
left: position.x,
top: position.y,
}
if (menuRef.current) {
const rect = menuRef.current.getBoundingClientRect()
if (position.x + rect.width > window.innerWidth) {
style.left = position.x - rect.width
}
if (position.y + rect.height > window.innerHeight) {
style.top = position.y - rect.height
}
}
return style
})()
return (
<div
ref={menuRef}
style={adjustedStyle}
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>
)
}
Step 2: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 3: Commit
git add frontend/src/components/common/ContextMenu.tsx
git commit -m "feat: add shared ContextMenu component"
Task 9: Create EditorAIPanel Component (Shell)
Files:
- Create:
frontend/src/components/editor-ai/EditorAIPanel.tsx - Create:
frontend/src/components/editor-ai/ChatTab.tsx - Create:
frontend/src/components/editor-ai/SuggestionsTab.tsx - Create:
frontend/src/components/editor-ai/NodeSummary.tsx
Step 1: Create NodeSummary component
Create frontend/src/components/editor-ai/NodeSummary.tsx:
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
import type { TreeStructure } from '@/types'
interface NodeSummaryProps {
node?: TreeStructure | null
flowName?: string
flowType?: 'troubleshooting' | 'procedural' | 'maintenance'
nodeCount?: number
}
const NODE_ICONS = {
decision: HelpCircle,
action: Zap,
solution: CheckCircle,
}
const NODE_COLORS = {
decision: 'text-blue-400',
action: 'text-yellow-400',
solution: 'text-green-400',
}
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
if (!node) {
// Flow summary when no node selected
return (
<div className="border-b border-border px-3 py-2.5">
<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 text-[0.625rem] text-muted-foreground font-label uppercase tracking-wider">
<span>{flowType || 'flow'}</span>
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
</div>
</div>
)
}
const Icon = NODE_ICONS[node.type as keyof typeof NODE_ICONS] || FileText
const colorClass = NODE_COLORS[node.type as keyof typeof NODE_COLORS] || 'text-muted-foreground'
return (
<div className="border-b border-border px-3 py-2.5">
<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-wider 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>
)
}
Step 2: Create ChatTab component
Create frontend/src/components/editor-ai/ChatTab.tsx:
import { useRef, useEffect } from 'react'
import { Send, Sparkles } from 'lucide-react'
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)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
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-3 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={`text-sm rounded-lg px-3 py-2 ${
msg.role === 'user'
? 'ml-6 bg-primary/15 text-foreground'
: 'mr-2 bg-[rgba(255,255,255,0.04)] border border-border text-foreground'
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
))}
{isLoading && (
<div className="mr-2 bg-[rgba(255,255,255,0.04)] border border-border rounded-lg px-3 py-2">
<div className="flex gap-1">
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce" />
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.15s]" />
<div className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-bounce [animation-delay:0.3s]" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="shrink-0 border-t border-border p-3">
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask AI to help..."
rows={1}
className="flex-1 resize-none rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
/>
<button
onClick={onSend}
disabled={!input.trim() || isLoading}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-brand text-[#101114] transition-all hover:opacity-90 active:scale-[0.97] disabled:opacity-40"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
Step 3: Create SuggestionsTab component
Create frontend/src/components/editor-ai/SuggestionsTab.tsx:
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' },
}
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-wider 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 text-[0.625rem] text-[#5a6170]">
{new Date(s.created_at).toLocaleDateString()}
</p>
</div>
)
})}
</div>
)
}
Step 4: Create the main EditorAIPanel component
Create frontend/src/components/editor-ai/EditorAIPanel.tsx:
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 { TreeStructure, EditorAIChatMessage, AISuggestion } from '@/types'
interface EditorAIPanelProps {
isOpen: boolean
onClose: () => void
// Context
focalNode?: TreeStructure | null
flowName?: string
flowType?: 'troubleshooting' | 'procedural' | 'maintenance'
nodeCount?: number
// Chat state
messages: EditorAIChatMessage[]
input: string
onInputChange: (value: string) => void
onSend: () => void
isLoading: boolean
// Suggestions
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-[320px] shrink-0 flex-col border-l border-border bg-[rgba(24,26,31,0.55)] backdrop-blur-[var(--glass-blur)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2.5 shrink-0">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<span className="text-sm font-medium text-foreground">AI Assist</span>
</div>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-[rgba(255,255,255,0.06)] hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Node/Flow Summary */}
<NodeSummary
node={focalNode}
flowName={flowName}
flowType={flowType}
nodeCount={nodeCount}
/>
{/* Tabs */}
<div className="flex border-b border-border shrink-0">
<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>
{/* Tab Content */}
{activeTab === 'chat' ? (
<ChatTab
messages={messages}
input={input}
onInputChange={onInputChange}
onSend={onSend}
isLoading={isLoading}
/>
) : (
<SuggestionsTab suggestions={suggestions} />
)}
</div>
)
}
Step 5: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 6: Commit
git add frontend/src/components/editor-ai/
git commit -m "feat: add EditorAIPanel component with Chat and Suggestions tabs"
Task 10: Create useEditorAI Hook
Files:
- Create:
frontend/src/hooks/useEditorAI.ts - Create:
frontend/src/api/editorAI.ts
Step 1: Create the API client
Create frontend/src/api/editorAI.ts:
import { apiClient } from './client'
import type { AIActionType } from '@/types'
export interface SendMessageParams {
sessionId: string
content: string
actionType?: AIActionType
focalNodeId?: string | 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 }: SendMessageParams) => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, {
content,
action_type: actionType || 'open_chat',
focal_node_id: focalNodeId,
})
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}`)
},
}
Step 2: Create the hook
Create frontend/src/hooks/useEditorAI.ts:
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
}
export function useEditorAI({ flowType, treeId }: UseEditorAIOptions) {
// Panel state
const [isOpen, setIsOpen] = useState(false)
const [focalNodeId, setFocalNodeId] = useState<string | null>(null)
// Context menu
const [contextMenu, setContextMenu] = useState<{
position: ContextMenuPosition
nodeId: string
} | null>(null)
// Chat state
const [sessionId, setSessionId] = useState<string | null>(null)
const [messages, setMessages] = useState<EditorAIChatMessage[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
// Suggestions
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 userMessage: EditorAIChatMessage = {
role: 'user',
content: input,
timestamp: new Date().toISOString(),
action_type: pendingActionRef.current,
}
setMessages((prev) => [...prev, userMessage])
setInput('')
setIsLoading(true)
try {
const sid = await ensureSession()
if (!sid) return
const result = await editorAIApi.sendMessage({
sessionId: sid,
content: input,
actionType: pendingActionRef.current,
focalNodeId: focalNodeId,
})
const assistantMessage: EditorAIChatMessage = {
role: 'assistant',
content: result.content,
timestamp: new Date().toISOString(),
}
setMessages((prev) => [...prev, assistantMessage])
// TODO: Parse delta from response and create ghost nodes
} 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])
const triggerAction = useCallback(
(nodeId: string, actionType: AIActionType, prompt: string) => {
setFocalNodeId(nodeId)
pendingActionRef.current = actionType
setInput(prompt)
setIsOpen(true)
// Auto-send after a tick so panel is visible
setTimeout(() => {
sendMessage()
}, 100)
},
[sendMessage]
)
return {
// Panel
isOpen,
openPanel,
closePanel,
focalNodeId,
setFocalNodeId,
// Context menu
contextMenu,
openContextMenu,
closeContextMenu,
// Chat
messages,
input,
setInput,
sendMessage,
isLoading,
// Suggestions
suggestions,
setSuggestions,
// Actions
triggerAction,
}
}
Step 3: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 4: Commit
git add frontend/src/hooks/useEditorAI.ts frontend/src/api/editorAI.ts
git commit -m "feat: add useEditorAI hook and editorAI API client"
Phase 3: Tree Editor Integration
Task 11: Add AI Panel to Tree Editor Layout
Files:
- Modify:
frontend/src/pages/TreeEditorPage.tsx
Step 1: Read the current file to understand its full structure
Read frontend/src/pages/TreeEditorPage.tsx before modifying.
Step 2: Add imports and hook
At the top of TreeEditorPage.tsx, add:
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
import { ContextMenu } from '@/components/common/ContextMenu'
import { useEditorAI } from '@/hooks/useEditorAI'
import { Sparkles } from 'lucide-react' // may already be imported
Inside the component, after existing hooks:
const editorAI = useEditorAI({
flowType: 'troubleshooting',
treeId: id,
})
Step 3: Add AI Assist toolbar button
In the toolbar section (around line 706, after the Export button), add:
<button
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors',
editorAI.isOpen
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.06)] hover:text-foreground'
)}
title="Toggle AI Assist"
>
<Sparkles className="h-4 w-4" />
<span className="hidden lg:inline">AI Assist</span>
</button>
Step 4: Handle single-panel rule
When AI panel opens, close the node editor. When AI panel closes, reopen node editor if a node was selected:
// Track the node that was being edited when AI panel opened
const previousEditingNodeRef = useRef<string | null>(null)
// When AI panel opens, remember editing node and close editor
useEffect(() => {
if (editorAI.isOpen && editingNodeId) {
previousEditingNodeRef.current = editingNodeId
setEditingNodeId(null)
}
}, [editorAI.isOpen])
// When AI panel closes, restore previous editing node
const handleAIPanelClose = () => {
editorAI.closePanel()
if (previousEditingNodeRef.current) {
setEditingNodeId(previousEditingNodeRef.current)
previousEditingNodeRef.current = null
}
}
Step 5: Add panel to layout
After the existing editor layout area (around line 782), add the AI panel alongside:
<div className="flex flex-1 overflow-hidden">
{/* Existing editor layout takes flex-1 */}
<div className="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>
{/* AI Panel */}
<EditorAIPanel
isOpen={editorAI.isOpen}
onClose={handleAIPanelClose}
focalNode={editorAI.focalNodeId ? findNode(editorAI.focalNodeId, treeStructure) : null}
flowName={name}
flowType="troubleshooting"
nodeCount={treeStructure ? getAllNodeIds(treeStructure).length : 0}
messages={editorAI.messages}
input={editorAI.input}
onInputChange={editorAI.setInput}
onSend={editorAI.sendMessage}
isLoading={editorAI.isLoading}
suggestions={editorAI.suggestions}
/>
</div>
Note: The onNodeContextMenu prop will need to be threaded through TreeEditorLayout → TreeCanvas → TreeCanvasNode. This happens in Task 12.
Step 6: Add context menu rendering
At the bottom of the component, before the closing fragment:
{editorAI.contextMenu && (
<ContextMenu
position={editorAI.contextMenu.position}
items={[
{
id: 'generate-branch',
label: 'Generate Branch',
icon: <Sparkles className="h-4 w-4" />,
onClick: () => editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'generate_branch',
`Generate a branch from node "${editorAI.contextMenu!.nodeId}"`
),
},
{
id: 'explain',
label: 'Explain Node',
icon: <HelpCircle className="h-4 w-4" />,
onClick: () => editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'quick_action',
`Explain what node "${editorAI.contextMenu!.nodeId}" does`
),
},
{ id: 'sep1', label: '', onClick: () => {}, separator: true },
{
id: 'delete',
label: 'Delete Node',
variant: 'danger' as const,
onClick: () => {
// Use existing delete logic
deleteNode(editorAI.contextMenu!.nodeId)
},
},
]}
onClose={editorAI.closeContextMenu}
/>
)}
Step 7: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds (some props like onNodeContextMenu may need stubbing if TreeEditorLayout doesn't accept it yet — add it as an optional prop first).
Step 8: Commit
git add frontend/src/pages/TreeEditorPage.tsx
git commit -m "feat: integrate AI panel and context menu in tree editor"
Task 12: Thread Context Menu Through Tree Canvas
Files:
- Modify:
frontend/src/components/tree-editor/TreeEditorLayout.tsx(or wherever TreeCanvas is rendered) - Modify:
frontend/src/components/tree-editor/TreeCanvas.tsx - Modify:
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Step 1: Read each file to understand prop threading
Read all three files before modifying.
Step 2: Add onNodeContextMenu prop through the chain
In TreeCanvasNode.tsx, add onContextMenu handler to the card's root div:
// In TreeCanvasNode props interface, add:
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
// On the card container div, add:
onContextMenu={(e) => onContextMenu?.(e, node.id)}
Thread this prop up through TreeCanvas.tsx and TreeEditorLayout.tsx from the TreeEditorPage.
Step 3: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 4: Commit
git add frontend/src/components/tree-editor/
git commit -m "feat: thread context menu handler through tree canvas components"
Task 13: Add Ghost Node Rendering to TreeCanvasNode
Files:
- Modify:
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Step 1: Read the current TreeCanvasNode styling
Read frontend/src/components/tree-editor/TreeCanvasNode.tsx.
Step 2: Add ghost node styling
In TreeCanvasNode.tsx, detect the _suggestion flag and apply ghost styling:
// After determining node type config (around line 90):
const isGhost = !!(node as Record<string, unknown>)._suggestion
// On the card container, modify className to conditionally add ghost styles:
className={cn(
'group relative rounded-xl border bg-card p-3 transition-all',
config.borderClass,
isSelected && 'ring-2 ring-primary/40',
hasErrors && 'ring-2 ring-rose-500/40',
hasWarnings && !hasErrors && 'ring-2 ring-amber-500/40',
// Ghost node styling
isGhost && 'border-dashed border-primary/40 opacity-60',
)}
Step 3: Add accept/dismiss overlay for ghost nodes
Inside the card, at the bottom, add:
{isGhost && (
<div className="mt-2 flex gap-2 border-t border-border/50 pt-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>
)}
Add onAcceptSuggestion and onDismissSuggestion to the component's props interface and thread them from the parent.
Step 4: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 5: Commit
git add frontend/src/components/tree-editor/TreeCanvasNode.tsx
git commit -m "feat: add ghost node rendering with accept/dismiss controls"
Phase 4: Procedural Editor Integration
Task 14: Add AI Panel to Procedural Editor Layout
Files:
- Modify:
frontend/src/pages/ProceduralEditorPage.tsx
Step 1: Read the current file
Read frontend/src/pages/ProceduralEditorPage.tsx to understand its layout.
Step 2: Add imports and hook
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
import { ContextMenu } from '@/components/common/ContextMenu'
import { useEditorAI } from '@/hooks/useEditorAI'
import { Sparkles } from 'lucide-react'
const editorAI = useEditorAI({
flowType: isMaintenance ? 'procedural' : 'procedural', // maintenance uses procedural
treeId: treeId,
})
Step 3: Wrap the step list area in a flex container
The procedural editor has no existing side panel. Wrap the main content area in a flex container so the AI panel can sit alongside:
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto">
{/* Existing step list, intake form, etc. */}
</div>
<EditorAIPanel
isOpen={editorAI.isOpen}
onClose={editorAI.closePanel}
focalNode={null} // TODO: adapt for steps
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>
Step 4: Add AI Assist toolbar button
Add the same toolbar toggle button as the tree editor (in the procedural editor's toolbar area).
Step 5: Add context menu for steps
Add onContextMenu handler to step items in StepList that opens the context menu with procedural-specific actions:
{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 step "${editorAI.contextMenu!.nodeId}"`
),
},
{
id: 'expand-step',
label: 'Expand Step',
icon: <Layers className="h-4 w-4" />,
onClick: () => editorAI.triggerAction(
editorAI.contextMenu!.nodeId,
'quick_action',
`Expand step "${editorAI.contextMenu!.nodeId}" into detailed substeps`
),
},
]}
onClose={editorAI.closeContextMenu}
/>
)}
Step 6: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 7: Commit
git add frontend/src/pages/ProceduralEditorPage.tsx
git commit -m "feat: integrate AI panel and context menu in procedural editor"
Task 15: Add Ghost Step Rendering to StepList
Files:
- Modify:
frontend/src/components/procedural-editor/StepList.tsx
Step 1: Read the current StepList component
Read frontend/src/components/procedural-editor/StepList.tsx.
Step 2: Add ghost step styling
For each step item in the list, detect _suggestion and apply ghost styling:
const isGhost = !!(step as Record<string, unknown>)._suggestion
// On the step container:
className={cn(
'rounded-xl border bg-card p-3',
// Ghost step styling
isGhost && 'border-l-2 border-dashed border-primary/40 opacity-60',
)}
Step 3: Add accept/dismiss controls for ghost steps
{isGhost && (
<div className="mt-2 flex gap-2">
<button
onClick={() => onAcceptSuggestion?.(step.id)}
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30"
>
Accept
</button>
<button
onClick={() => onDismissSuggestion?.(step.id)}
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30"
>
Dismiss
</button>
</div>
)}
Step 4: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 5: Commit
git add frontend/src/components/procedural-editor/StepList.tsx
git commit -m "feat: add ghost step rendering with accept/dismiss in step list"
Phase 5: AI-Assisted Create + Prompt Dialog
Task 16: Create AI Prompt Dialog Modal
Files:
- Create:
frontend/src/components/editor-ai/AIPromptDialog.tsx
Step 1: Create the component
Create frontend/src/components/editor-ai/AIPromptDialog.tsx:
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="relative w-full max-w-lg rounded-2xl border border-border bg-card 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>
)
}
Step 2: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 3: Commit
git add frontend/src/components/editor-ai/AIPromptDialog.tsx
git commit -m "feat: add AI prompt dialog for AI-assisted flow creation"
Task 17: Update CreateFlowDropdown
Files:
- Modify:
frontend/src/components/common/CreateFlowDropdown.tsx
Step 1: Read the current file
Read frontend/src/components/common/CreateFlowDropdown.tsx.
Step 2: Replace /ai/chat navigation with prompt dialog
Remove the navigate('/ai/chat') and navigate('/ai/chat?type=procedural') calls. Instead:
- Add state for the prompt dialog:
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<'troubleshooting' | 'procedural' | 'maintenance'>('troubleshooting')
- Replace AI builder menu items to open the dialog:
// Instead of navigate('/ai/chat')
onClick={() => {
setAiPromptFlowType('troubleshooting')
setAiPromptOpen(true)
}}
- Add the
AIPromptDialogat the end of the component:
<AIPromptDialog
isOpen={aiPromptOpen}
onClose={() => setAiPromptOpen(false)}
flowType={aiPromptFlowType}
onGenerate={async (prompt) => {
// 1. Start session with generate_full action
// 2. Send the prompt
// 3. Generate the tree
// 4. Import to editor
// 5. Navigate to editor with AI panel open flag
const session = await editorAIApi.startSession(aiPromptFlowType)
await editorAIApi.sendMessage({
sessionId: session.session_id,
content: prompt,
actionType: 'generate_full',
})
const result = await editorAIApi.generateFull(session.session_id)
// Import the generated tree
const imported = await apiClient.post(`/ai/chat/sessions/${session.session_id}/import`, {})
// Navigate to editor with AI panel open
const path = aiPromptFlowType === 'troubleshooting'
? `/trees/${imported.data.tree_id}/edit`
: `/flows/${imported.data.tree_id}/edit`
navigate(path, { state: { aiPanelOpen: true, sessionId: session.session_id } })
}}
/>
Step 3: Verify build
Run: cd frontend && npm run build
Expected: Build succeeds.
Step 4: Commit
git add frontend/src/components/common/CreateFlowDropdown.tsx
git commit -m "feat: replace /ai/chat navigation with AI prompt dialog in create dropdown"
Phase 6: Remove Old Standalone Flow Assist
Task 18: Remove Old AI Chat Components
Files:
- Delete:
frontend/src/pages/AIChatBuilderPage.tsx - Delete:
frontend/src/store/aiChatStore.ts - Delete:
frontend/src/components/ai-chat/(entire directory) - Modify:
frontend/src/router.tsx(remove/ai/chatroute) - Delete: Any
AIFlowBuilderModalcomponent (search for it)
Step 1: Search for all references to removed files
Search for imports of AIChatBuilderPage, aiChatStore, ai-chat/ components, and AIFlowBuilderModal across the frontend.
Step 2: Remove the route
In frontend/src/router.tsx, remove:
- The lazy import:
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage')) - The route entry:
{ path: 'ai/chat', element: ... }
Step 3: Remove any sidebar/nav references to /ai/chat
Search for /ai/chat in navigation components and remove links.
Step 4: Delete the files
rm frontend/src/pages/AIChatBuilderPage.tsx
rm frontend/src/store/aiChatStore.ts
rm -rf frontend/src/components/ai-chat/
Also search for and remove AIFlowBuilderModal if it exists:
# Search: grep -r "AIFlowBuilderModal" frontend/src/
# Delete the file if found
Step 5: Fix any remaining import errors
Run: cd frontend && npm run build
Fix any broken imports referencing deleted files.
Step 6: Verify build clean
Run: cd frontend && npm run build
Expected: Build succeeds with no errors.
Step 7: Commit
git add -A
git commit -m "refactor: remove standalone Flow Assist page and old AI chat components"
Phase 7: Backend Action-Type Prompt Dispatch
Task 19: Add Delta Response Parsing to AI Chat Service
Files:
- Modify:
backend/app/core/ai_chat_service.py
Step 1: Write the failing test
Create backend/tests/test_ai_delta_response.py:
"""Tests for AI delta response parsing."""
from app.core.ai_chat_service import AIChatService
def test_parse_delta_from_response():
"""Service extracts [DELTA] markers from AI responses."""
service = AIChatService()
response = '''Here's a new branch for that node.
[DELTA]
{"action": "add", "target_node_id": "check-dns", "nodes": [{"id": "verify-dns-server", "type": "decision", "question": "Is the DNS server responding?"}], "explanation": "Added DNS verification branch"}
[/DELTA]
Let me know if you'd like to adjust this.'''
parsed = service._parse_delta(response)
assert parsed is not None
assert parsed["action"] == "add"
assert parsed["target_node_id"] == "check-dns"
assert len(parsed["nodes"]) == 1
def test_parse_delta_none_when_absent():
"""Returns None when no delta marker present."""
service = AIChatService()
response = "Sure, I can explain that node. It checks connectivity."
parsed = service._parse_delta(response)
assert parsed is None
Step 2: Run test to verify it fails
Run: cd backend && python -m pytest tests/test_ai_delta_response.py -v
Expected: FAIL — _parse_delta does not exist.
Step 3: Add _parse_delta method
In backend/app/core/ai_chat_service.py, add:
import re
import json
def _parse_delta(self, response: str) -> dict | None:
"""Extract [DELTA]...[/DELTA] JSON from AI response."""
match = re.search(r'\[DELTA\](.*?)\[/DELTA\]', response, re.DOTALL)
if not match:
return None
raw = self._strip_markdown_fences(match.group(1).strip())
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
Step 4: Run test to verify it passes
Run: cd backend && python -m pytest tests/test_ai_delta_response.py -v
Expected: All PASS.
Step 5: Commit
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_delta_response.py
git commit -m "feat: add delta response parsing to AI chat service"
Task 20: Add Action-Type Prompt Dispatch
Files:
- Modify:
backend/app/core/ai_chat_service.py
Step 1: Write the failing test
Add to backend/tests/test_ai_delta_response.py:
def test_build_action_prompt_generate_branch():
"""Generate branch action includes focal node context."""
service = AIChatService()
tree = {
"id": "root",
"type": "decision",
"question": "Is the server up?",
"children": [],
"options": [],
}
prompt = service._build_action_prompt(
action_type="generate_branch",
focal_node_id="root",
tree_structure=tree,
flow_type="troubleshooting",
)
assert "root" in prompt
assert "generate" in prompt.lower() or "branch" in prompt.lower()
def test_build_action_prompt_open_chat():
"""Open chat action is general conversation."""
service = AIChatService()
prompt = service._build_action_prompt(
action_type="open_chat",
focal_node_id=None,
tree_structure={"id": "root", "type": "decision"},
flow_type="troubleshooting",
)
assert isinstance(prompt, str)
assert len(prompt) > 0
Step 2: Run test to verify it fails
Run: cd backend && python -m pytest tests/test_ai_delta_response.py -v
Expected: FAIL — _build_action_prompt does not exist.
Step 3: Add the method
In backend/app/core/ai_chat_service.py, add:
def _build_action_prompt(
self,
action_type: str,
focal_node_id: str | None,
tree_structure: dict,
flow_type: str,
) -> str:
"""Build action-specific system prompt supplement."""
import json
tree_json = json.dumps(tree_structure, indent=2)
focal_context = ""
if focal_node_id:
focal_node = self._find_node_by_id(tree_structure, focal_node_id)
if focal_node:
focal_context = f"\n\nFOCAL NODE (the node being acted on):\n{json.dumps(focal_node, indent=2)}"
prompts = {
"generate_branch": (
f"Generate a complete branch of child nodes for the focal node. "
f"Return the new nodes wrapped in [DELTA]...[/DELTA] markers as JSON with "
f"action='add', target_node_id='{focal_node_id}', and nodes array."
f"{focal_context}"
),
"modify_node": (
f"Modify the focal node based on the user's instruction. "
f"Return the updated node in [DELTA]...[/DELTA] markers with action='modify'."
f"{focal_context}"
),
"add_steps": (
f"Generate new procedural steps to insert after the focal step. "
f"Return them in [DELTA]...[/DELTA] markers with action='add'."
f"{focal_context}"
),
"quick_action": (
f"Respond to the user's quick action request about the focal node. "
f"If the action modifies the node, return changes in [DELTA]...[/DELTA] markers. "
f"If it's informational (e.g. explain), just respond in text."
f"{focal_context}"
),
"open_chat": (
"Have a helpful conversation about the flow. If the user asks for changes, "
"return them in [DELTA]...[/DELTA] markers. Otherwise respond in text."
),
"generate_full": (
"Generate a complete flow structure based on the user's description."
),
"variable_inference": (
"Analyze the procedural steps for implicit variables. Look for references to "
"specific servers, clients, credentials, or other values that should be captured "
"in an intake form. Return suggestions as JSON."
),
}
action_prompt = prompts.get(action_type, prompts["open_chat"])
return (
f"CURRENT FLOW STRUCTURE ({flow_type}):\n{tree_json}\n\n"
f"ACTION: {action_type}\n{action_prompt}"
)
def _find_node_by_id(self, tree: dict, node_id: str) -> dict | None:
"""Find a node by ID in a tree structure (recursive)."""
if tree.get("id") == node_id:
return tree
for child in tree.get("children", []):
found = self._find_node_by_id(child, node_id)
if found:
return found
# For procedural steps
for step in tree.get("steps", []):
if step.get("id") == node_id:
return step
return None
Step 4: Run tests
Run: cd backend && python -m pytest tests/test_ai_delta_response.py -v
Expected: All PASS.
Step 5: Integrate into send_message
In the send_message method, after building the system prompt, append the action prompt:
# In send_message, after building the base system prompt:
if action_type and action_type != "open_chat":
action_supplement = self._build_action_prompt(
action_type=action_type,
focal_node_id=focal_node_id,
tree_structure=session.working_tree or {},
flow_type=session.flow_type,
)
# Append to system prompt or add as a user context message
Step 6: Run full test suite
Run: cd backend && python -m pytest tests/test_ai_chat.py tests/test_ai_delta_response.py -v
Expected: All PASS.
Step 7: Commit
git add backend/app/core/ai_chat_service.py backend/tests/test_ai_delta_response.py
git commit -m "feat: add action-type prompt dispatch with delta response format"
Phase 8: Suggestion Endpoints + Archive
Task 21: Add AI Suggestion CRUD Endpoints
Files:
- Create:
backend/app/schemas/ai_suggestion.py - Create:
backend/app/api/endpoints/ai_suggestions.py - Modify:
backend/app/api/router.py
Step 1: Create schemas
Create backend/app/schemas/ai_suggestion.py:
"""Schemas for AI suggestion audit trail."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class AISuggestionCreate(BaseModel):
tree_id: UUID
session_id: Optional[UUID] = None
action_type: str
target_node_id: Optional[str] = None
changes_json: dict = Field(default_factory=dict)
class AISuggestionResponse(BaseModel):
id: UUID
tree_id: UUID
user_id: UUID
session_id: Optional[UUID]
action_type: str
target_node_id: Optional[str]
changes_json: dict
status: str
created_at: datetime
resolved_at: Optional[datetime]
model_config = {"from_attributes": True}
class AISuggestionResolve(BaseModel):
status: str = Field(..., pattern="^(accepted|dismissed)$")
Step 2: Create endpoints
Create backend/app/api/endpoints/ai_suggestions.py:
"""AI Suggestion audit trail endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from app.api.deps import get_current_active_user, get_db
from app.models.user import User
from app.models.ai_suggestion import AISuggestion
from app.schemas.ai_suggestion import (
AISuggestionCreate,
AISuggestionResponse,
AISuggestionResolve,
)
router = APIRouter(prefix="/ai/suggestions", tags=["ai-suggestions"])
@router.get("/tree/{tree_id}", response_model=list[AISuggestionResponse])
async def list_suggestions(
tree_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""List all suggestions for a flow, filtered to current user."""
result = await db.execute(
select(AISuggestion)
.where(AISuggestion.tree_id == tree_id, AISuggestion.user_id == current_user.id)
.order_by(AISuggestion.created_at.desc())
)
return result.scalars().all()
@router.post("", response_model=AISuggestionResponse, status_code=201)
async def create_suggestion(
data: AISuggestionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""Record a new AI suggestion."""
suggestion = AISuggestion(
tree_id=data.tree_id,
user_id=current_user.id,
session_id=data.session_id,
action_type=data.action_type,
target_node_id=data.target_node_id,
changes_json=data.changes_json,
)
db.add(suggestion)
await db.commit()
await db.refresh(suggestion)
return suggestion
@router.patch("/{suggestion_id}", response_model=AISuggestionResponse)
async def resolve_suggestion(
suggestion_id: UUID,
data: AISuggestionResolve,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""Accept or dismiss a suggestion."""
result = await db.execute(
select(AISuggestion).where(
AISuggestion.id == suggestion_id,
AISuggestion.user_id == current_user.id,
)
)
suggestion = result.scalar_one_or_none()
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
suggestion.status = data.status
suggestion.resolved_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(suggestion)
return suggestion
Step 3: Register in router
In backend/app/api/router.py, add:
from app.api.endpoints.ai_suggestions import router as ai_suggestions_router
api_router.include_router(ai_suggestions_router)
Step 4: Write tests
Create backend/tests/test_ai_suggestions.py:
"""Tests for AI suggestion endpoints."""
import pytest
@pytest.mark.asyncio
async def test_create_and_list_suggestions(client, auth_headers, test_tree):
"""Can create and list suggestions for a tree."""
tree_id = test_tree["id"]
# Create suggestion
resp = await client.post(
"/api/v1/ai/suggestions",
json={
"tree_id": tree_id,
"action_type": "generate_branch",
"target_node_id": "some-node",
"changes_json": {"before": {}, "after": {"id": "new-node"}},
},
headers=auth_headers,
)
assert resp.status_code == 201
suggestion_id = resp.json()["id"]
assert resp.json()["status"] == "pending"
# List suggestions
resp = await client.get(
f"/api/v1/ai/suggestions/tree/{tree_id}",
headers=auth_headers,
)
assert resp.status_code == 200
assert len(resp.json()) >= 1
# Resolve suggestion
resp = await client.patch(
f"/api/v1/ai/suggestions/{suggestion_id}",
json={"status": "accepted"},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
assert resp.json()["resolved_at"] is not None
Step 5: Run tests
Run: cd backend && python -m pytest tests/test_ai_suggestions.py -v
Expected: All PASS.
Step 6: Commit
git add backend/app/schemas/ai_suggestion.py backend/app/api/endpoints/ai_suggestions.py backend/app/api/router.py backend/tests/test_ai_suggestions.py
git commit -m "feat: add AI suggestion audit trail endpoints"
Task 22: Add Session Auto-Archive APScheduler Task
Files:
- Modify:
backend/app/main.py(or wherever APScheduler is configured)
Step 1: Find existing APScheduler setup
Search for APScheduler or add_job in backend/app/main.py or backend/app/core/.
Step 2: Add the archive job
Add a new scheduled job function:
async def archive_stale_ai_sessions():
"""Archive AI chat sessions with no activity for 30 days."""
from app.core.database import async_session_maker
from app.models.ai_chat_session import AIChatSession
from sqlalchemy import update
from datetime import datetime, timezone, timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
async with async_session_maker() as db:
await db.execute(
update(AIChatSession)
.where(
AIChatSession.updated_at < cutoff,
AIChatSession.archived_at.is_(None),
AIChatSession.status != "abandoned",
)
.values(archived_at=datetime.now(timezone.utc))
)
await db.commit()
Register it in the scheduler (follow existing pattern):
scheduler.add_job(
archive_stale_ai_sessions,
'cron',
hour=3, # Run daily at 3 AM
id='archive_stale_ai_sessions',
replace_existing=True,
)
Step 3: Verify the app starts
Run: cd backend && uvicorn app.main:app --reload
Expected: App starts without errors, job registered.
Step 4: Commit
git add backend/app/main.py
git commit -m "feat: add APScheduler task to auto-archive stale AI chat sessions"
Phase 9: Final Integration + Build Verification
Task 23: Frontend Build Verification
Step 1: Run full frontend build
Run: cd frontend && npm run build
Expected: Build succeeds with zero errors.
Step 2: Fix any remaining issues
Address any TypeScript errors, unused imports, or missing type exports.
Step 3: Commit fixes if needed
git add -A
git commit -m "fix: resolve build issues from editor AI integration"
Task 24: Backend Test Suite Verification
Step 1: Run full backend test suite
Run: cd backend && python -m pytest --override-ini="addopts=" -v
Expected: All tests PASS. No regressions.
Step 2: Fix any failures
Address any test failures from the changes.
Step 3: Commit fixes if needed
git add -A
git commit -m "fix: resolve test regressions from editor AI integration"
Task 25: Update Documentation
Files:
- Modify:
CURRENT-STATE.md - Modify:
CLAUDE.md(if new patterns/lessons discovered)
Step 1: Update CURRENT-STATE.md
Move "AI chat session conclusion" items and add:
- In Progress: Editor-Embedded Flow Assist (AI panel in tree + procedural editors)
- Recently Completed: (update as appropriate)
Step 2: Update CLAUDE.md if needed
Add any new lessons learned (e.g., ghost node pattern, zundo pause/resume, action-type routing).
Step 3: Commit
git add CURRENT-STATE.md CLAUDE.md
git commit -m "docs: update project status for editor-embedded Flow Assist"
Summary
| Phase | Tasks | Description |
|---|---|---|
| 1 | 1-6 | Bug fix + backend foundation (config, models, migrations, schemas) |
| 2 | 7-10 | Frontend infrastructure (types, ContextMenu, EditorAIPanel, useEditorAI hook) |
| 3 | 11-13 | Tree editor integration (panel layout, context menu threading, ghost nodes) |
| 4 | 14-15 | Procedural editor integration (panel layout, ghost steps) |
| 5 | 16-17 | AI-assisted create (prompt dialog, CreateFlowDropdown update) |
| 6 | 18 | Remove old standalone Flow Assist |
| 7 | 19-20 | Backend action-type prompt dispatch + delta response |
| 8 | 21-22 | Suggestion endpoints + auto-archive |
| 9 | 23-25 | Build verification + docs |
Deferred to future work:
- Knowledge integration (Phase 1: Microsoft Learn MCP)
- Intake variable inference (tiers 2-3: natural language + cross-step)
- Ghost node grouping for 5+ suggestions ("Accept Branch" controls)
- Before/after diff view for modified-selected-node in chat