Files
resolutionflow/docs/plans/archive/2026-03-06-editor-embedded-flow-assist-plan.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -04:00

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 TreeEditorLayoutTreeCanvasTreeCanvasNode. 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:

  1. Add state for the prompt dialog:
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<'troubleshooting' | 'procedural' | 'maintenance'>('troubleshooting')
  1. Replace AI builder menu items to open the dialog:
// Instead of navigate('/ai/chat')
onClick={() => {
  setAiPromptFlowType('troubleshooting')
  setAiPromptOpen(true)
}}
  1. Add the AIPromptDialog at 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/chat route)
  • Delete: Any AIFlowBuilderModal component (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