diff --git a/backend/app/api/endpoints/ai_builder.py b/backend/app/api/endpoints/ai_builder.py
index 099cf539..6ea2c935 100644
--- a/backend/app/api/endpoints/ai_builder.py
+++ b/backend/app/api/endpoints/ai_builder.py
@@ -11,9 +11,11 @@ import logging
from typing import Annotated
import anthropic
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
+from app.core.rate_limit import limiter
+
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_conversation_store import (
@@ -86,7 +88,9 @@ async def get_quota(
@router.post("/start", response_model=AIStartResponse, status_code=201)
+@limiter.limit("10/minute")
async def start_conversation(
+ request: Request,
data: AIStartRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -140,7 +144,9 @@ async def start_conversation(
@router.post("/scaffold", response_model=AIScaffoldResponse)
+@limiter.limit("10/minute")
async def scaffold(
+ request: Request,
data: AIScaffoldRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -249,7 +255,9 @@ async def scaffold(
@router.post("/branch-detail", response_model=AIBranchDetailResponse)
+@limiter.limit("10/minute")
async def branch_detail(
+ request: Request,
data: AIBranchDetailRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
@@ -364,7 +372,9 @@ async def branch_detail(
@router.post("/assemble", response_model=AIAssembleResponse)
+@limiter.limit("10/minute")
async def assemble(
+ request: Request,
data: AIAssembleRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
diff --git a/backend/app/core/ai_quota_service.py b/backend/app/core/ai_quota_service.py
index 1f89ec8b..c4d26b9e 100644
--- a/backend/app/core/ai_quota_service.py
+++ b/backend/app/core/ai_quota_service.py
@@ -4,6 +4,7 @@ Enforces monthly and daily limits on AI flow builder usage.
Monthly quota consumed only on successful tree assembly (counts_toward_quota=True).
Daily limit is an anti-abuse guard consumed on conversation start.
"""
+import calendar
from datetime import datetime, timezone, timedelta
from typing import Optional
from uuid import UUID
@@ -127,9 +128,13 @@ async def check_ai_quota(
deny_reason = "daily"
# Calculate reset timestamps
+ next_month = month_start.month % 12 + 1
+ next_year = month_start.year + (1 if month_start.month == 12 else 0)
+ max_day = calendar.monthrange(next_year, next_month)[1]
monthly_reset_at = month_start.replace(
- month=month_start.month % 12 + 1,
- year=month_start.year + (1 if month_start.month == 12 else 0),
+ month=next_month,
+ year=next_year,
+ day=min(month_start.day, max_day),
)
daily_reset_at = day_start + timedelta(hours=24)
diff --git a/backend/app/core/ai_tree_validator.py b/backend/app/core/ai_tree_validator.py
index b58ef28d..fa6bc6dc 100644
--- a/backend/app/core/ai_tree_validator.py
+++ b/backend/app/core/ai_tree_validator.py
@@ -159,7 +159,7 @@ def _check_branch_termination(node: dict[str, Any], errors: list[str]) -> None:
if node_type == "solution":
return # Solution is a valid terminus
- if not children and node_type != "solution":
+ if not children:
errors.append(
f"Node '{node_id}' (type={node_type}) is a dead end — "
"it has no children and is not a solution node"
diff --git a/backend/app/schemas/ai_builder.py b/backend/app/schemas/ai_builder.py
index 67e4e87d..0c2f5baa 100644
--- a/backend/app/schemas/ai_builder.py
+++ b/backend/app/schemas/ai_builder.py
@@ -2,7 +2,7 @@
from typing import Any, Literal, Optional
from uuid import UUID
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
# ── Requests ──
@@ -17,7 +17,17 @@ class AIStartRequest(BaseModel):
category_id: Optional[UUID] = None
name: str = Field(..., min_length=1, max_length=255)
description: str = Field("", max_length=2000)
- environment_tags: list[str] = Field(default_factory=list)
+ environment_tags: list[str] = Field(default_factory=list, max_length=20)
+
+ @field_validator("environment_tags")
+ @classmethod
+ def validate_tags(cls, v: list[str]) -> list[str]:
+ for tag in v:
+ if len(tag) > 100:
+ raise ValueError("Each environment tag must be 100 characters or fewer")
+ if not tag.strip():
+ raise ValueError("Environment tags must not be empty")
+ return v
class AIScaffoldRequest(BaseModel):
diff --git a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx
index 5d1b0494..8bbbd362 100644
--- a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx
+++ b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Modal } from '@/components/common/Modal'
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
@@ -34,9 +34,11 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
}
}, [isOpen, loadQuota])
- // Auto-trigger scaffold after conversation starts
+ // Auto-trigger scaffold after conversation starts (ref prevents double-fire)
+ const hasTriggeredScaffold = useRef(false)
useEffect(() => {
- if (phase === 'scaffolding' && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
+ if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
+ hasTriggeredScaffold.current = true
scaffold()
}
}, [phase, scaffold])
diff --git a/frontend/src/components/ai-builder/BranchDetailView.tsx b/frontend/src/components/ai-builder/BranchDetailView.tsx
index 8f4eb31d..25baba07 100644
--- a/frontend/src/components/ai-builder/BranchDetailView.tsx
+++ b/frontend/src/components/ai-builder/BranchDetailView.tsx
@@ -200,8 +200,8 @@ function NodePreview({ node, depth }: { node: Record; depth: nu
{label}
{type}
- {children.map((child, i) => (
-
+ {children.map((child) => (
+
))}
)
diff --git a/frontend/src/components/common/CreateFlowDropdown.tsx b/frontend/src/components/common/CreateFlowDropdown.tsx
new file mode 100644
index 00000000..d39ed29c
--- /dev/null
+++ b/frontend/src/components/common/CreateFlowDropdown.tsx
@@ -0,0 +1,93 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+interface CreateFlowDropdownProps {
+ aiEnabled: boolean
+ onOpenAIBuilder: () => void
+ className?: string
+ /** Button label — defaults to "Create Flow" */
+ label?: string
+}
+
+export function CreateFlowDropdown({
+ aiEnabled,
+ onOpenAIBuilder,
+ className,
+ label = 'Create Flow',
+}: CreateFlowDropdownProps) {
+ const [showMenu, setShowMenu] = useState(false)
+
+ return (
+
+
+ {showMenu && (
+ <>
+
setShowMenu(false)} />
+
+
setShowMenu(false)}
+ className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
+ >
+
+
+
Troubleshooting Tree
+
Branching decision flow
+
+
+
setShowMenu(false)}
+ className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
+ >
+
+
+
Procedural Flow
+
Step-by-step procedure
+
+
+
setShowMenu(false)}
+ className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
+ >
+
+
+
Maintenance Flow
+
Scheduled multi-target tasks
+
+
+ {aiEnabled && (
+ <>
+
+
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/hooks/useCachedQuota.ts b/frontend/src/hooks/useCachedQuota.ts
index bc26b13c..32f3175f 100644
--- a/frontend/src/hooks/useCachedQuota.ts
+++ b/frontend/src/hooks/useCachedQuota.ts
@@ -5,6 +5,11 @@ const CACHE_TTL_MS = 5 * 60 * 1000
let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null
+/** Clear the cached quota (call on logout to prevent stale data across users). */
+export function clearCachedQuota() {
+ cachedResult = null
+}
+
export function useCachedQuota() {
const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false)
const [isLoading, setIsLoading] = useState(!cachedResult)
diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx
index aeb005ba..179f9e0a 100644
--- a/frontend/src/pages/QuickStartPage.tsx
+++ b/frontend/src/pages/QuickStartPage.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'
-import { useNavigate, Link } from 'react-router-dom'
-import { Search, Plus, Loader2, Star, ChevronDown, ChevronLeft, ChevronRight, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
+import { useNavigate } from 'react-router-dom'
+import { Search, Loader2, Star, ChevronLeft, ChevronRight } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
@@ -19,6 +19,7 @@ import { TreeListView } from '@/components/library/TreeListView'
import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
+import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { cn } from '@/lib/utils'
function timeAgo(dateStr: string): string {
@@ -61,8 +62,7 @@ export function QuickStartPage() {
// Favorites state
const [showAllFavorites, setShowAllFavorites] = useState(false)
- // Create menu + AI Builder
- const [showCreateMenu, setShowCreateMenu] = useState(false)
+ // AI Builder
const [showAIBuilder, setShowAIBuilder] = useState(false)
const { aiEnabled } = useCachedQuota()
@@ -212,13 +212,7 @@ export function QuickStartPage() {
// Handlers
const handleStartSession = (treeId: string, treeType?: string) => {
- if (treeType === 'maintenance') {
- navigate(`/flows/${treeId}/maintenance`)
- } else if (treeType === 'procedural') {
- navigate(`/flows/${treeId}/navigate`)
- } else {
- navigate(`/trees/${treeId}/navigate`)
- }
+ navigate(getTreeNavigatePath(treeId, treeType))
}
const handleDeleteTree = () => {} // Not used on dashboard
@@ -242,75 +236,10 @@ export function QuickStartPage() {
{canCreateTrees && (
-
-
- {showCreateMenu && (
- <>
-
setShowCreateMenu(false)} />
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Troubleshooting Tree
-
Branching decision flow
-
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Procedural Flow
-
Step-by-step procedure
-
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Maintenance Flow
-
Scheduled multi-target tasks
-
-
- {aiEnabled && (
- <>
-
-
- >
- )}
-
- >
- )}
-
+
setShowAIBuilder(true)}
+ />
)}
@@ -444,13 +373,11 @@ export function QuickStartPage() {
You haven't created any flows yet.
{canCreateTrees && (
-
+
setShowAIBuilder(true)}
+ label="Create your first flow"
+ />
)}
) : (
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx
index 57f38738..59c9d90a 100644
--- a/frontend/src/pages/TreeLibraryPage.tsx
+++ b/frontend/src/pages/TreeLibraryPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
-import { useNavigate, Link, useSearchParams } from 'react-router-dom'
-import { Plus, X, RotateCcw, Play, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { X, RotateCcw, Play } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -14,12 +14,13 @@ import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle'
import { SortDropdown } from '@/components/library/SortDropdown'
import { cn, safeGetItem } from '@/lib/utils'
-import { getSessionResumePath } from '@/lib/routing'
+import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
import { useCachedQuota } from '@/hooks/useCachedQuota'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
+import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { toast } from '@/lib/toast'
export function TreeLibraryPage() {
@@ -72,8 +73,7 @@ export function TreeLibraryPage() {
// Fork state
const [isForkingTree, setIsForkingTree] = useState(false)
- // Create menu & AI builder state
- const [showCreateMenu, setShowCreateMenu] = useState(false)
+ // AI builder state
const [showAIBuilder, setShowAIBuilder] = useState(false)
const { aiEnabled } = useCachedQuota()
@@ -215,13 +215,7 @@ export function TreeLibraryPage() {
}
const handleStartSession = (treeId: string, treeType?: string) => {
- if (treeType === 'maintenance') {
- navigate(`/flows/${treeId}/maintenance`)
- } else if (treeType === 'procedural') {
- navigate(`/flows/${treeId}/navigate`)
- } else {
- navigate(`/trees/${treeId}/navigate`)
- }
+ navigate(getTreeNavigatePath(treeId, treeType))
}
const handleCreateFolder = (parentId?: string | null) => {
@@ -285,78 +279,11 @@ export function TreeLibraryPage() {
{canCreateTrees && (
-
-
- {showCreateMenu && (
- <>
-
setShowCreateMenu(false)} />
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Troubleshooting Tree
-
Branching decision flow
-
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Procedural Flow
-
Step-by-step procedure
-
-
-
setShowCreateMenu(false)}
- className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
- >
-
-
-
Maintenance Flow
-
Scheduled multi-target tasks
-
-
- {aiEnabled && (
- <>
-
-
- >
- )}
-
- >
- )}
-
+
setShowAIBuilder(true)}
+ label="Create New"
+ />
)}
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
index e06fbc32..a883f6d8 100644
--- a/frontend/src/store/authStore.ts
+++ b/frontend/src/store/authStore.ts
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'
import type { User, Token, UserCreate, UserLogin, Account, SubscriptionDetails } from '@/types'
import { authApi } from '@/api/auth'
import { apiClient } from '@/api/client'
+import { clearCachedQuota } from '@/hooks/useCachedQuota'
interface AuthState {
user: User | null
@@ -79,6 +80,7 @@ export const useAuthStore = create()(
} finally {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
+ clearCachedQuota()
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
}
},