Files
resolutionflow/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md
chihlasm 0dc6123c0c feat: flow export/import + procedural Flow Assist (#96)
* feat: add flow export/import backend (migration, endpoints, schemas)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add editor-embedded Flow Assist design document

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

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

* docs: add editor-embedded Flow Assist implementation plan

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

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

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

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

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

* feat: add config-driven AI model tier routing

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

* feat: extend AI chat session with tree_id and archived_at

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

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

* feat: add AI suggestion audit trail table

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

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

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

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

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

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

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

* feat: add TypeScript types for editor-embedded AI

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

* feat: add shared ContextMenu component

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

* feat: add useEditorAI hook and editorAI API client

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

* feat: add EditorAIPanel component with Chat and Suggestions tabs

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

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

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

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

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

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

* feat: add AI prompt dialog and wire into CreateFlowDropdown

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

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

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

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

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

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

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

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

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

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

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

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

* feat: add AI suggestion audit trail endpoints

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

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

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

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

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

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

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

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

* fix: use correct model alias in AI_MODEL_TIERS standard tier

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:51:37 -05:00

2803 lines
79 KiB
Markdown

# 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:
```typescript
// 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**
```bash
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`:
```python
"""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:
```python
# 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**
```bash
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:
```python
# 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):
```python
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`:
```python
"""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**
```bash
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`:
```python
"""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:
```python
from app.models.ai_suggestion import AISuggestion # noqa: F401
```
**Step 3: Create migration manually**
Create `backend/alembic/versions/052_add_ai_suggestion_table.py`:
```python
"""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**
```bash
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`):
```python
"""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`:
```python
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`:
```python
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:
```python
# 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:
```python
# 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`:
```python
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**
```bash
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`:
```python
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:
```python
# 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:
```python
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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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`:
```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**
```bash
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`:
```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`:
```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`:
```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`:
```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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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:
```typescript
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:
```typescript
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:
```tsx
<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:
```typescript
// 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:
```tsx
<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:
```tsx
{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**
```bash
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:
```tsx
// 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**
```bash
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:
```tsx
// 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:
```tsx
{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**
```bash
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**
```typescript
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
import { ContextMenu } from '@/components/common/ContextMenu'
import { useEditorAI } from '@/hooks/useEditorAI'
import { Sparkles } from 'lucide-react'
```
```typescript
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:
```tsx
<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:
```tsx
{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**
```bash
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:
```tsx
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**
```tsx
{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**
```bash
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`:
```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**
```bash
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:
```tsx
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<'troubleshooting' | 'procedural' | 'maintenance'>('troubleshooting')
```
2. Replace AI builder menu items to open the dialog:
```tsx
// Instead of navigate('/ai/chat')
onClick={() => {
setAiPromptFlowType('troubleshooting')
setAiPromptOpen(true)
}}
```
3. Add the `AIPromptDialog` at the end of the component:
```tsx
<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**
```bash
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**
```bash
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:
```bash
# 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**
```bash
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`:
```python
"""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:
```python
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**
```bash
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`:
```python
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:
```python
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:
```python
# 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**
```bash
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`:
```python
"""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`:
```python
"""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:
```python
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`:
```python
"""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**
```bash
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:
```python
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):
```python
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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