From 64c22a0f71c4ddb088ed665c345b4ed4e6259d2e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 23:02:53 -0500 Subject: [PATCH] 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 --- ...-03-06-editor-embedded-flow-assist-plan.md | 2802 +++++++++++++++++ 1 file changed, 2802 insertions(+) create mode 100644 docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md diff --git a/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md b/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md new file mode 100644 index 00000000..bf18ae93 --- /dev/null +++ b/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md @@ -0,0 +1,2802 @@ +# 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[] + explanation: string +} + +export interface AISuggestion { + id: string + action_type: AIActionType + target_node_id: string | null + changes_json: { + before?: Record + after?: Record + 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(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 ( +
+ {items.map((item) => ( +
+ {item.separator && ( +
+ )} + +
+ ))} +
+ ) +} +``` + +**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 ( +
+
+ + + {flowName || 'Untitled Flow'} + +
+
+ {flowType || 'flow'} + {nodeCount !== undefined && {nodeCount} nodes} +
+
+ ) + } + + 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 ( +
+
+ + + {node.type} + +
+

+ {node.question || node.title || node.id} +

+ {node.description && ( +

+ {node.description} +

+ )} +
+ ) +} +``` + +**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(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (input.trim() && !isLoading) onSend() + } + } + + return ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+ +

Ask me to help build your flow

+
+ )} + {messages.map((msg, i) => ( +
+

{msg.content}

+
+ ))} + {isLoading && ( +
+
+
+
+
+
+
+ )} +
+
+ + {/* Input */} +
+
+