feat: AI flow builder, visibility model, dashboard tabs, fork UI (#88)
- AI flow builder: scaffold → branch detail → assemble → review flow - Generate All one-click branch generation with stop/cancel - Regenerate scaffold suggestions button - 3-action review screen: Start Flow, Open in Editor, Build Another - Fix Publish button gated behind !isDirty - Fix visibility column enforcement in tree access filter - Add ?visibility filter and author_name to GET /trees - Dashboard tabbed flows: My Flows / My Team / Public / All - Create button in My Flows tab, window focus reload (stale data fix) - Fork UI with optional reason modal - Fix account_id nullability in User type and schema - Keep is_public and visibility in sync on updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #88.
This commit is contained in:
@@ -32,7 +32,7 @@ from app.core.tree_validation import can_publish_tree
|
||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||
|
||||
|
||||
def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
def build_tree_response(tree: Tree, author_map: dict | None = None) -> TreeListResponse:
|
||||
"""Build TreeListResponse with category_info and tags."""
|
||||
category_info = None
|
||||
if tree.category_rel:
|
||||
@@ -42,6 +42,8 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
slug=tree.category_rel.slug
|
||||
)
|
||||
|
||||
author_name = (author_map or {}).get(tree.author_id)
|
||||
|
||||
return TreeListResponse(
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
@@ -52,10 +54,12 @@ def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
category_info=category_info,
|
||||
tags=tree.tag_names,
|
||||
author_id=tree.author_id,
|
||||
author_name=author_name,
|
||||
account_id=tree.account_id,
|
||||
is_active=tree.is_active,
|
||||
is_public=tree.is_public,
|
||||
is_default=tree.is_default,
|
||||
visibility=tree.visibility,
|
||||
status=tree.status,
|
||||
version=tree.version,
|
||||
usage_count=tree.usage_count,
|
||||
@@ -125,6 +129,7 @@ async def list_trees(
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
author_id: Optional[UUID] = Query(None, description="Filter by author ID"),
|
||||
is_public: Optional[bool] = Query(None, description="Filter by public status"),
|
||||
visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
|
||||
sort_by: Optional[str] = Query("usage_count", description="Sort order: usage_count, updated_at, created_at, name, name_desc, version"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=100)
|
||||
@@ -158,6 +163,8 @@ async def list_trees(
|
||||
query = query.where(Tree.author_id == author_id)
|
||||
if is_public is not None:
|
||||
query = query.where(Tree.is_public == is_public)
|
||||
if visibility:
|
||||
query = query.where(Tree.visibility == visibility)
|
||||
|
||||
# Filter by tags (all specified tags must be present)
|
||||
if tags:
|
||||
@@ -206,7 +213,17 @@ async def list_trees(
|
||||
result = await db.execute(query)
|
||||
trees = result.scalars().unique().all()
|
||||
|
||||
return [build_tree_response(tree) for tree in trees]
|
||||
# Fetch author names in one query (avoids N+1)
|
||||
author_ids = {t.author_id for t in trees if t.author_id}
|
||||
author_map: dict = {}
|
||||
if author_ids:
|
||||
authors_result = await db.execute(
|
||||
select(User.id, User.name, User.email).where(User.id.in_(author_ids))
|
||||
)
|
||||
for row in authors_result:
|
||||
author_map[row.id] = row.name or row.email
|
||||
|
||||
return [build_tree_response(tree, author_map) for tree in trees]
|
||||
|
||||
|
||||
@router.get("/categories", response_model=list[str])
|
||||
@@ -612,6 +629,13 @@ async def update_tree(
|
||||
for field, value in update_data.items():
|
||||
setattr(tree, field, value)
|
||||
|
||||
# Keep visibility and is_public in sync
|
||||
if tree_data.is_public is not None:
|
||||
if tree_data.is_public and tree.visibility not in ('public',):
|
||||
tree.visibility = 'public'
|
||||
elif not tree_data.is_public and tree.visibility == 'public':
|
||||
tree.visibility = 'team' # downgrade from public to team
|
||||
|
||||
# Increment version if tree structure changed
|
||||
if "tree_structure" in update_data:
|
||||
tree.version += 1
|
||||
@@ -1057,6 +1081,7 @@ async def update_tree_visibility(
|
||||
# Update visibility
|
||||
old_visibility = tree.visibility
|
||||
tree.visibility = visibility_data.visibility
|
||||
tree.is_public = (visibility_data.visibility == 'public')
|
||||
|
||||
await log_audit(db, current_user.id, "tree.visibility.update", "tree", tree.id,
|
||||
{"tree_name": tree.name, "old_visibility": old_visibility,
|
||||
|
||||
@@ -97,7 +97,16 @@ CORRECTIVE_PROMPT_TEMPLATE = """Your previous JSON was invalid for ResolutionFlo
|
||||
Validation errors:
|
||||
{error_list}
|
||||
|
||||
IMPORTANT: If any error mentions a next_node_id referencing a non-existent child, you must ensure every option's next_node_id exactly matches the "id" field of one of the node's direct children. The child node must exist in the "children" array of the same parent node.
|
||||
CRITICAL RULES TO FIX THESE ERRORS:
|
||||
|
||||
1. Decision node options → next_node_id MUST match the "id" of a direct child in that SAME decision node's "children" array.
|
||||
Example: if decision node has children [A, B, C], then option next_node_id must be "A", "B", or "C".
|
||||
|
||||
2. Action node → next_node_id MUST match the "id" of a SIBLING node — another child of the SAME parent decision node.
|
||||
Example: if parent decision has children [action-1, solution-1, solution-2], then action-1's next_node_id must be "solution-1" or "solution-2".
|
||||
The next node must ALREADY EXIST in the parent's children array — do NOT nest the next node inside the action node.
|
||||
|
||||
3. Every referenced ID must physically exist somewhere in the tree as a node with that exact "id" value.
|
||||
|
||||
Return a corrected full JSON object only. No markdown, no prose, no code fences.
|
||||
Fix ALL listed errors while maintaining the same troubleshooting/procedural logic."""
|
||||
@@ -224,6 +233,12 @@ async def generate_branch_detail(
|
||||
len(response.content),
|
||||
response.usage.output_tokens,
|
||||
)
|
||||
if response.stop_reason == "max_tokens":
|
||||
logger.warning(
|
||||
"branch_detail attempt=%d hit max_tokens limit (%d output tokens) — response may be truncated",
|
||||
attempt,
|
||||
response.usage.output_tokens,
|
||||
)
|
||||
raw_text = _strip_markdown_fences(response.content[0].text) if response.content else ""
|
||||
if not raw_text:
|
||||
logger.warning("branch_detail attempt=%d returned empty text, stop_reason=%s", attempt, response.stop_reason)
|
||||
|
||||
@@ -40,7 +40,8 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
|
||||
|
||||
# Collect all node IDs and validate structure
|
||||
all_ids: set[str] = set()
|
||||
all_referenced_ids: set[str] = set()
|
||||
all_referenced_ids: set[str] = set() # option next_node_ids (already checked locally)
|
||||
action_next_ids: set[str] = set() # action next_node_ids (checked globally below)
|
||||
node_count = 0
|
||||
solution_count = 0
|
||||
|
||||
@@ -121,6 +122,7 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
|
||||
)
|
||||
else:
|
||||
all_referenced_ids.add(next_id)
|
||||
action_next_ids.add(next_id)
|
||||
|
||||
elif node_type == "solution":
|
||||
solution_count += 1
|
||||
@@ -131,6 +133,13 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
|
||||
|
||||
_validate_node(tree, "root")
|
||||
|
||||
# Check that all action next_node_ids actually exist in the tree
|
||||
for ref_id in action_next_ids:
|
||||
if ref_id not in all_ids:
|
||||
errors.append(
|
||||
f"Action next_node_id '{ref_id}' references a node that does not exist in the tree"
|
||||
)
|
||||
|
||||
# Global checks
|
||||
if node_count < 5:
|
||||
errors.append(
|
||||
|
||||
@@ -16,24 +16,32 @@ if TYPE_CHECKING:
|
||||
def build_tree_access_filter(current_user: User):
|
||||
"""Build the access filter for trees based on user permissions.
|
||||
|
||||
Returns trees that are:
|
||||
- All trees (for super admins)
|
||||
- Default/system trees (visible to all)
|
||||
- Public trees
|
||||
- User's own trees
|
||||
- Trees from user's account
|
||||
Visibility rules:
|
||||
- super_admin: sees everything
|
||||
- is_default: visible to all authenticated users
|
||||
- visibility='public': visible to all authenticated users
|
||||
- author_id == me: always visible (regardless of visibility setting)
|
||||
- visibility='team' AND account_id == mine: visible to account members
|
||||
- visibility='private': only visible to author (covered by author_id check above)
|
||||
- visibility='link': only visible to author (share token access is handled separately)
|
||||
"""
|
||||
from app.models.tree import Tree
|
||||
|
||||
if current_user.is_super_admin:
|
||||
return sa_true()
|
||||
|
||||
conditions = [
|
||||
Tree.is_default == True,
|
||||
Tree.is_public == True,
|
||||
Tree.visibility == 'public',
|
||||
Tree.author_id == current_user.id,
|
||||
]
|
||||
if current_user.account_id:
|
||||
conditions.append(Tree.account_id == current_user.account_id)
|
||||
conditions.append(
|
||||
and_(
|
||||
Tree.visibility == 'team',
|
||||
Tree.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
|
||||
@@ -176,10 +176,12 @@ class TreeListResponse(BaseModel):
|
||||
category_info: Optional[CategoryInfo] = None
|
||||
tags: list[str] = [] # List of tag names
|
||||
author_id: Optional[UUID] = None
|
||||
author_name: Optional[str] = None # Display name or email of author
|
||||
account_id: Optional[UUID] = None
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
visibility: str = 'team'
|
||||
status: str # draft or published
|
||||
version: int
|
||||
usage_count: int
|
||||
|
||||
@@ -40,8 +40,8 @@ class UserLogin(BaseModel):
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
role: str = "engineer"
|
||||
account_id: UUID
|
||||
account_role: str
|
||||
account_id: Optional[UUID] = None
|
||||
account_role: Optional[str] = None
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
must_change_password: bool = False
|
||||
|
||||
@@ -124,6 +124,13 @@ class TestReferenceIntegrity:
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("non-existent child" in e for e in errors)
|
||||
|
||||
def test_action_next_node_id_references_nonexistent_node(self):
|
||||
"""Action next_node_id pointing to a node that doesn't exist anywhere in the tree."""
|
||||
tree = _make_valid_tree()
|
||||
tree["children"][1]["next_node_id"] = "ghost-node"
|
||||
errors = validate_generated_tree(tree)
|
||||
assert any("ghost-node" in e for e in errors)
|
||||
|
||||
def test_duplicate_option_ids(self):
|
||||
tree = _make_valid_tree()
|
||||
tree["options"][0]["id"] = "same"
|
||||
|
||||
@@ -391,3 +391,59 @@ class TestTrees:
|
||||
assert response.status_code == 200
|
||||
trees = response.json()
|
||||
assert isinstance(trees, list)
|
||||
|
||||
|
||||
class TestVisibilityFilter:
|
||||
"""Test that visibility filtering and author_name work correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_tree_only_visible_to_author(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""A private tree created by test_user should appear in their own list."""
|
||||
tree_data = {
|
||||
"name": "Private Flow Test",
|
||||
"tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
|
||||
}
|
||||
create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert create_resp.status_code == 201
|
||||
tree_id = create_resp.json()["id"]
|
||||
|
||||
# Set visibility to private
|
||||
vis_resp = await client.patch(
|
||||
f"/api/v1/trees/{tree_id}/visibility",
|
||||
json={"visibility": "private"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert vis_resp.status_code == 200
|
||||
|
||||
# Verify it still appears for the author
|
||||
list_resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert list_resp.status_code == 200
|
||||
ids = [t["id"] for t in list_resp.json()]
|
||||
assert tree_id in ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_query_param_filters_correctly(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""?visibility=public should only return trees with visibility='public'."""
|
||||
resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
for tree in trees:
|
||||
assert tree["visibility"] == "public", f"Tree {tree['id']} has visibility={tree['visibility']}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_author_name_present_in_list_response(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""TreeListResponse should include author_name field."""
|
||||
resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
assert len(trees) >= 1
|
||||
# author_name key should be present (value may be None for system/default trees)
|
||||
assert "author_name" in trees[0]
|
||||
# visibility key should be present
|
||||
assert "visibility" in trees[0]
|
||||
|
||||
354
docs/plans/2026-02-24-ai-builder-ux-improvements-plan.md
Normal file
354
docs/plans/2026-02-24-ai-builder-ux-improvements-plan.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# AI Builder UX Improvements Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Three frontend-only UX improvements: always-available Publish button, "Generate All" branches in the AI wizard, and rotating activity messages during generation.
|
||||
|
||||
**Architecture:** All changes are frontend-only. Feature 1 is a one-line fix in `TreeEditorPage.tsx`. Features 2 and 3 involve adding state to `aiFlowBuilderStore.ts` and updating `BranchDetailView.tsx` and `GeneratingAnimation.tsx`. No backend changes.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React icons.
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-24-ai-builder-ux-improvements.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix Publish button — remove `!isDirty` gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeEditorPage.tsx` (line ~654)
|
||||
|
||||
**Step 1: Make the change**
|
||||
|
||||
Find this line in `TreeEditorPage.tsx`:
|
||||
```tsx
|
||||
disabled={isSaving || !isDirty || hasBlockingErrors}
|
||||
```
|
||||
Change to:
|
||||
```tsx
|
||||
disabled={isSaving || hasBlockingErrors}
|
||||
```
|
||||
That's the only change needed. The button's `title` text references "Ctrl+S when no errors" which is still accurate — leave it.
|
||||
|
||||
**Step 2: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/TreeEditorPage.tsx
|
||||
git commit -m "fix: allow publishing clean drafts without requiring local edits"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add rotating activity messages to `GeneratingAnimation`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ai-builder/GeneratingAnimation.tsx`
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read `frontend/src/components/ai-builder/GeneratingAnimation.tsx` to understand current structure before changing it.
|
||||
|
||||
**Step 2: Rewrite with rotating messages**
|
||||
|
||||
Replace the entire file content with:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MESSAGES = [
|
||||
'Setting up your flow...',
|
||||
'Building diagnostic paths...',
|
||||
'Putting the pieces in place...',
|
||||
'Almost there...',
|
||||
] as const
|
||||
|
||||
const MESSAGE_DURATIONS = [4000, 8000, 8000, Infinity] // ms each message shows
|
||||
|
||||
interface GeneratingAnimationProps {
|
||||
branchContext?: { current: number; total: number }
|
||||
}
|
||||
|
||||
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
|
||||
const [messageIndex, setMessageIndex] = useState(0)
|
||||
|
||||
// Reset and advance message on mount/remount
|
||||
useEffect(() => {
|
||||
setMessageIndex(0)
|
||||
let current = 0
|
||||
|
||||
const advance = () => {
|
||||
current += 1
|
||||
if (current < MESSAGES.length - 1) {
|
||||
setMessageIndex(current)
|
||||
timer = setTimeout(advance, MESSAGE_DURATIONS[current])
|
||||
} else {
|
||||
setMessageIndex(MESSAGES.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
let timer = setTimeout(advance, MESSAGE_DURATIONS[0])
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
{/* Spinner */}
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
|
||||
{/* Branch context (Generate All mode) */}
|
||||
{branchContext && (
|
||||
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
|
||||
Branch {branchContext.current} of {branchContext.total}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rotating message */}
|
||||
<p
|
||||
key={messageIndex}
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground transition-opacity duration-500',
|
||||
)}
|
||||
>
|
||||
{MESSAGES[messageIndex]}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ai-builder/GeneratingAnimation.tsx
|
||||
git commit -m "feat: add rotating activity messages to generation loading state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add `generateAllBranchDetails` and cancel to the store
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/store/aiFlowBuilderStore.ts`
|
||||
|
||||
**Step 1: Read the current store**
|
||||
|
||||
Read `frontend/src/store/aiFlowBuilderStore.ts` fully to understand current state shape before modifying.
|
||||
|
||||
**Step 2: Add new state fields and actions**
|
||||
|
||||
Add to the `AIFlowBuilderState` interface (after `isLoading: boolean`):
|
||||
```tsx
|
||||
isGeneratingAll: boolean
|
||||
stopGeneratingAll: boolean
|
||||
generateAllBranchDetails: () => Promise<void>
|
||||
cancelGenerateAll: () => void
|
||||
```
|
||||
|
||||
Add to the initial state in `create()(...)` (after `isLoading: false`):
|
||||
```tsx
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
```
|
||||
|
||||
Add the two new actions after `assemble`:
|
||||
|
||||
```tsx
|
||||
generateAllBranchDetails: async () => {
|
||||
const { selectedBranches, generateBranchDetail } = get()
|
||||
const undetailed = selectedBranches.filter((b) => !b.steps)
|
||||
if (undetailed.length === 0) return
|
||||
|
||||
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
|
||||
|
||||
for (const branch of undetailed) {
|
||||
if (get().stopGeneratingAll) break
|
||||
// Set currentBranchIndex so tabs show the active branch
|
||||
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
if (idx !== -1) set({ currentBranchIndex: idx })
|
||||
await generateBranchDetail(branch.name)
|
||||
// If generateBranchDetail set phase to 'error', stop
|
||||
if (get().phase === 'error') break
|
||||
}
|
||||
|
||||
set({ isGeneratingAll: false })
|
||||
},
|
||||
|
||||
cancelGenerateAll: () => {
|
||||
set({ stopGeneratingAll: true })
|
||||
},
|
||||
```
|
||||
|
||||
Also add `isGeneratingAll: false, stopGeneratingAll: false` to the `reset()` action's `set({...})` call.
|
||||
|
||||
**Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/store/aiFlowBuilderStore.ts
|
||||
git commit -m "feat: add generateAllBranchDetails and cancelGenerateAll to AI builder store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update `BranchDetailView` with Generate All UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/ai-builder/BranchDetailView.tsx`
|
||||
|
||||
**Step 1: Read the current file**
|
||||
|
||||
Read `frontend/src/components/ai-builder/BranchDetailView.tsx` fully.
|
||||
|
||||
**Step 2: Add imports and pull new store state**
|
||||
|
||||
Add `Zap, Square` to the lucide-react import (Zap = lightning bolt for "Generate All", Square = stop).
|
||||
|
||||
Pull new state from store in the component:
|
||||
```tsx
|
||||
const {
|
||||
// existing...
|
||||
isGeneratingAll,
|
||||
generateAllBranchDetails,
|
||||
cancelGenerateAll,
|
||||
} = useAIFlowBuilderStore()
|
||||
```
|
||||
|
||||
**Step 3: Add Generate All / Stop button above branch tabs**
|
||||
|
||||
After the opening `<div className="flex flex-col gap-4">` and before the branch tabs div, add:
|
||||
|
||||
```tsx
|
||||
{/* Generate All / Stop control */}
|
||||
{(() => {
|
||||
const undetailedCount = selectedBranches.filter((b) => !b.steps).length
|
||||
if (undetailedCount === 0) return null
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{undetailedCount} branch{undetailedCount !== 1 ? 'es' : ''} need detail
|
||||
</span>
|
||||
{isGeneratingAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelGenerateAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-400/20"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateAllBranchDetails}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-3 py-1.5 text-xs font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
Generate All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
```
|
||||
|
||||
**Step 4: Disable individual controls during `isGeneratingAll`**
|
||||
|
||||
On the "Generate Detail" button (the primary one in the empty-state section):
|
||||
```tsx
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
```
|
||||
|
||||
On the "Skip" button:
|
||||
```tsx
|
||||
disabled={isGeneratingAll}
|
||||
// also add: className includes opacity-50 when disabled
|
||||
```
|
||||
|
||||
On the "Regenerate" button:
|
||||
```tsx
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
```
|
||||
|
||||
**Step 5: Pass `branchContext` to `GeneratingAnimation`**
|
||||
|
||||
The `GeneratingAnimation` is rendered when `phase === 'generating' && isLoading`. Update that render:
|
||||
|
||||
```tsx
|
||||
if (phase === 'generating' && isLoading) {
|
||||
return (
|
||||
<GeneratingAnimation
|
||||
branchContext={
|
||||
isGeneratingAll
|
||||
? {
|
||||
current: selectedBranches.filter((b) => b.steps).length + 1,
|
||||
total: selectedBranches.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 6: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build 2>&1 | tail -20
|
||||
```
|
||||
Expected: no errors.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ai-builder/BranchDetailView.tsx
|
||||
git commit -m "feat: add Generate All button and per-branch progress to AI builder detail stage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Push and verify
|
||||
|
||||
**Step 1: Push branch**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
**Step 2: Verify CI passes**
|
||||
|
||||
```bash
|
||||
gh pr checks 88 2>&1 | head -20
|
||||
```
|
||||
|
||||
Expected: all checks passing (or wait for them to run).
|
||||
|
||||
**Step 3: Manual smoke test checklist**
|
||||
|
||||
- [ ] Open a fresh AI-generated draft in the tree editor → Publish button is enabled
|
||||
- [ ] Open AI Flow Builder, complete foundation → scaffold → select branches
|
||||
- [ ] On detail stage: "Generate All" button is visible
|
||||
- [ ] Click "Generate All" → branches generate one at a time, tabs show progress, "Branch X of Y" appears in animation
|
||||
- [ ] "Stop" button appears during run, clicking it halts after current branch
|
||||
- [ ] Activity messages cycle: "Setting up your flow..." → "Building diagnostic paths..." → "Putting the pieces in place..." → "Almost there..."
|
||||
- [ ] Single-branch generate still works as before
|
||||
119
docs/plans/2026-02-24-ai-builder-ux-improvements.md
Normal file
119
docs/plans/2026-02-24-ai-builder-ux-improvements.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# AI Builder UX Improvements
|
||||
|
||||
> **Date:** 2026-02-24
|
||||
> **Branch:** frontend-standardization (PR #88)
|
||||
> **Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three focused UX improvements to the AI Flow Builder and tree editor:
|
||||
|
||||
1. **Publish always available** — remove unnecessary `!isDirty` gate on the Publish button
|
||||
2. **Generate All branches** — one-click auto-generation of all branch details sequentially
|
||||
3. **Honest activity messages** — replace spinner-only loading with rotating status messages
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Publish Button Always Available
|
||||
|
||||
### Problem
|
||||
The Publish button in `TreeEditorPage.tsx` is disabled when `!isDirty`. This prevents publishing a freshly AI-generated draft that landed in the editor already saved — the tree is valid and ready, but the button is greyed out because nothing has been locally changed.
|
||||
|
||||
This same issue affects any future import/ingestion pathway where a draft arrives pre-saved.
|
||||
|
||||
### Solution
|
||||
Remove `!isDirty` from the Publish button's `disabled` condition. Keep it disabled only for `isSaving || hasBlockingErrors`.
|
||||
|
||||
The Save Draft button retains its `!isDirty` guard (no reason to re-save an unchanged draft).
|
||||
|
||||
### Change
|
||||
**File:** `frontend/src/pages/TreeEditorPage.tsx`
|
||||
**Line:** ~654
|
||||
Before: `disabled={isSaving || !isDirty || hasBlockingErrors}`
|
||||
After: `disabled={isSaving || hasBlockingErrors}`
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Generate All Branches
|
||||
|
||||
### Problem
|
||||
With 4–7 branches, users must click "Generate Detail" for each branch individually, wait for it to complete, then click the next. For 6 branches this is 6 separate interactions with wait time between each.
|
||||
|
||||
### Solution
|
||||
Add a "Generate All" button that auto-generates all undetailed branches sequentially without user intervention.
|
||||
|
||||
### Store changes (`aiFlowBuilderStore.ts`)
|
||||
Add:
|
||||
- `isGeneratingAll: boolean` — true while auto-run is in progress
|
||||
- `stopGeneratingAll: boolean` — flag set by user to cancel mid-run
|
||||
- `generateAllBranchDetails(): Promise<void>` — sequential loop
|
||||
- `cancelGenerateAll(): void` — sets stop flag
|
||||
|
||||
`generateAllBranchDetails()` logic:
|
||||
1. Set `isGeneratingAll: true`, `stopGeneratingAll: false`
|
||||
2. Loop through `selectedBranches` in order, skipping branches that already have `steps`
|
||||
3. For each: set `currentBranchIndex` to the active branch (so tabs show live progress), call `generateBranchDetail(branch.name)`
|
||||
4. After each call, check `stopGeneratingAll` — if true, break
|
||||
5. On `generateBranchDetail` failure: stop loop, set `isGeneratingAll: false`, leave error state on that branch (existing error handling handles display)
|
||||
6. On complete: set `isGeneratingAll: false`
|
||||
|
||||
### UI changes (`BranchDetailView.tsx`)
|
||||
- Add "Generate All" button above branch tabs, visible when ≥1 branch has no `steps` and `!isGeneratingAll`
|
||||
- During a run: button becomes "Stop" (calls `cancelGenerateAll`)
|
||||
- Individual "Generate Detail" and "Skip" buttons disabled during `isGeneratingAll`
|
||||
- Branch tabs show `currentBranchIndex` highlight during run so user can see which is active
|
||||
- When `isGeneratingAll`, show "Branch X of Y" context in the generating animation
|
||||
|
||||
### Error handling
|
||||
On failure mid-run: stop at the failed branch, show the existing error indicator on that branch tab (red instead of green check), display the error message. User can retry that branch individually or skip it. No global abort.
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Honest Activity Messages
|
||||
|
||||
### Problem
|
||||
`GeneratingAnimation` shows a spinner with static text. For waits of 5–30+ seconds this gives no sense of progress.
|
||||
|
||||
### Solution
|
||||
Replace the static text in `GeneratingAnimation` with rotating messages tied to elapsed time. Messages are always-true descriptions of what the system is doing at a high level.
|
||||
|
||||
### Message sequence
|
||||
| Elapsed | Message |
|
||||
|---------|---------|
|
||||
| 0–4s | "Setting up your flow..." |
|
||||
| 4–12s | "Building diagnostic paths..." |
|
||||
| 12–20s | "Putting the pieces in place..." |
|
||||
| 20s+ | "Almost there..." |
|
||||
|
||||
Messages advance on a timer that resets each time generation starts. They use `useEffect` with `setInterval` — no new dependencies needed.
|
||||
|
||||
### Generate All context
|
||||
When `isGeneratingAll` is true, show "Branch X of Y" as a small label above the rotating message. This comes from the store's `currentBranchIndex` and `selectedBranches.length`.
|
||||
|
||||
### Change
|
||||
**File:** `frontend/src/components/ai-builder/GeneratingAnimation.tsx`
|
||||
Replace static text with a `useEffect`-driven message cycler. Accept optional `branchContext?: { current: number; total: number }` prop.
|
||||
|
||||
`BranchDetailView.tsx` passes `branchContext` when `isGeneratingAll` is true.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Remove `!isDirty` from Publish disabled condition |
|
||||
| `frontend/src/store/aiFlowBuilderStore.ts` | Add `isGeneratingAll`, `stopGeneratingAll`, `generateAllBranchDetails`, `cancelGenerateAll` |
|
||||
| `frontend/src/components/ai-builder/BranchDetailView.tsx` | Add Generate All / Stop button, disable controls during run, pass branch context |
|
||||
| `frontend/src/components/ai-builder/GeneratingAnimation.tsx` | Add rotating activity messages with elapsed timer, accept branchContext prop |
|
||||
|
||||
No backend changes required.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
- SSE/streaming status from backend (deferred, would be needed for accurate retry messaging)
|
||||
- Parallel branch generation (rate limit risk, sequential is safer and shows progress)
|
||||
- Persisting generate-all state across modal close (not needed, user can re-run)
|
||||
686
docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
Normal file
686
docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
Normal file
@@ -0,0 +1,686 @@
|
||||
# Visibility Model, Dashboard Tabs & Fork UI — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Fix the dashboard stale-data bug, wire up the existing `visibility` column into access control, replace the single-list "My Flows" dashboard with a tabbed All / My Team / Public / My Flows view, and add a Fork button to public flow cards.
|
||||
|
||||
**Architecture:** Backend: rewrite `build_tree_access_filter` to use `visibility` column, add `visibility` query param to `GET /trees`, add `author_name` to `TreeListResponse`. Frontend: convert `MyTreesPage` into a tabbed page with per-tab API calls; add Fork button on public/team cards with a minimal confirmation modal. No new migrations needed — all columns exist. `is_public` stays in sync with `visibility='public'` for backward compat.
|
||||
|
||||
**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Pydantic v2, React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React.
|
||||
|
||||
**Key files:**
|
||||
- `backend/app/core/filters.py` — access filter (currently ignores `visibility`)
|
||||
- `backend/app/api/endpoints/trees.py` — list endpoint (add `visibility` param, `author_name` in response)
|
||||
- `backend/app/schemas/tree.py` — add `author_name`, `visibility` to `TreeListResponse`
|
||||
- `backend/tests/test_trees.py` — add visibility filter tests
|
||||
- `frontend/src/types/tree.ts` — add `visibility`, `author_name` to `TreeListItem`
|
||||
- `frontend/src/api/trees.ts` — add `visibility` param to `list()`
|
||||
- `frontend/src/pages/MyTreesPage.tsx` — full rewrite with tabs + stale data fix + fork button
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix `build_tree_access_filter` to enforce `visibility`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/filters.py`
|
||||
|
||||
### Background
|
||||
|
||||
Currently the filter uses `is_public` boolean and `author_id`/`account_id` equality. The `visibility` column (`private | team | link | public`) is completely ignored. This means:
|
||||
- `visibility='private'` trees are still visible to team members (wrong)
|
||||
- The column is dead code
|
||||
|
||||
We keep `is_public` in sync (`is_public = visibility == 'public'`) since other code may read it. The new filter logic is:
|
||||
|
||||
| Condition | Sees tree |
|
||||
|---|---|
|
||||
| `is_default == True` | Everyone |
|
||||
| `visibility == 'public'` | Everyone |
|
||||
| `author_id == me` | Always (regardless of visibility) |
|
||||
| `visibility == 'team' AND account_id == mine` | Team members (not private ones) |
|
||||
|
||||
`visibility='private'` trees are only ever visible to their author.
|
||||
`visibility='link'` trees are accessible via share token (already handled by share endpoints); they don't appear in list queries unless you are the author.
|
||||
|
||||
**Step 1: Rewrite `build_tree_access_filter`**
|
||||
|
||||
Replace the function body in `backend/app/core/filters.py`:
|
||||
|
||||
```python
|
||||
def build_tree_access_filter(current_user: User):
|
||||
"""Build the access filter for trees based on user permissions.
|
||||
|
||||
Visibility rules:
|
||||
- super_admin: sees everything
|
||||
- is_default: visible to all authenticated users
|
||||
- visibility='public': visible to all authenticated users
|
||||
- author_id == me: always visible (regardless of visibility setting)
|
||||
- visibility='team' AND account_id == mine: visible to account members
|
||||
- visibility='private': only visible to author (covered by author_id check above)
|
||||
- visibility='link': only visible to author (share token access is handled separately)
|
||||
"""
|
||||
from app.models.tree import Tree
|
||||
|
||||
if current_user.is_super_admin:
|
||||
return sa_true()
|
||||
|
||||
conditions = [
|
||||
Tree.is_default == True,
|
||||
Tree.visibility == 'public',
|
||||
Tree.author_id == current_user.id,
|
||||
]
|
||||
if current_user.account_id:
|
||||
conditions.append(
|
||||
and_(
|
||||
Tree.visibility == 'team',
|
||||
Tree.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
return or_(*conditions)
|
||||
```
|
||||
|
||||
**Step 2: Verify no existing tests break**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all existing tests pass (the change narrows visibility but test trees are created with default `visibility='team'` and are owned by test user so they pass via `author_id` match).
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/filters.py
|
||||
git commit -m "fix: enforce visibility column in tree access filter
|
||||
|
||||
Previously build_tree_access_filter used is_public boolean and ignored the
|
||||
visibility column entirely. Now private/link trees are only visible to their
|
||||
author, team trees require matching account_id, and public trees are open to all.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `visibility` query param and `author_name` to list endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/trees.py`
|
||||
- Modify: `backend/app/schemas/tree.py`
|
||||
- Modify: `backend/app/models/user.py` (confirm `full_name` or `email` field name)
|
||||
|
||||
### Background
|
||||
|
||||
The frontend tabs need to filter by visibility scope:
|
||||
- "My Flows" tab: `author_id=<me>` (existing)
|
||||
- "My Team" tab: `visibility=team` (new)
|
||||
- "Public" tab: `visibility=public` (new)
|
||||
- "All" tab: no visibility filter (existing default behavior)
|
||||
|
||||
We also want to show "Created by X" on cards that don't belong to the current user. The `TreeListResponse` needs an `author_name` field (email or display name).
|
||||
|
||||
**Step 1: Add `author_name` to `TreeListResponse` in `backend/app/schemas/tree.py`**
|
||||
|
||||
Add to the `TreeListResponse` class after `author_id`:
|
||||
```python
|
||||
author_name: Optional[str] = None # Display name or email of author
|
||||
```
|
||||
|
||||
**Step 2: Add `visibility` to `TreeListResponse` in `backend/app/schemas/tree.py`**
|
||||
|
||||
Add to `TreeListResponse` after `is_default`:
|
||||
```python
|
||||
visibility: str = 'team'
|
||||
```
|
||||
|
||||
**Step 3: Update `build_tree_response` in `backend/app/api/endpoints/trees.py`**
|
||||
|
||||
The `build_tree_response` function needs to include `author_name` and `visibility`. However, since it receives a `Tree` object (not a joined query), we need to either:
|
||||
a) Eagerly load the `author` relationship, or
|
||||
b) Set `author_name` from a pre-built map
|
||||
|
||||
The cleanest approach is to update `list_trees` to eagerly load the `author` relationship and pass it through.
|
||||
|
||||
In `list_trees`, update the query to load author:
|
||||
```python
|
||||
query = select(Tree).options(
|
||||
selectinload(Tree.category_rel),
|
||||
selectinload(Tree.tags),
|
||||
selectinload(Tree.author), # ADD THIS
|
||||
)
|
||||
```
|
||||
|
||||
Update `build_tree_response` signature and body:
|
||||
```python
|
||||
def build_tree_response(tree: Tree) -> TreeListResponse:
|
||||
"""Build TreeListResponse with category_info, tags, author_name, and visibility."""
|
||||
category_info = None
|
||||
if tree.category_rel:
|
||||
category_info = CategoryInfo(
|
||||
id=tree.category_rel.id,
|
||||
name=tree.category_rel.name,
|
||||
slug=tree.category_rel.slug
|
||||
)
|
||||
|
||||
# Author display: prefer full_name, fall back to email
|
||||
author_name = None
|
||||
if tree.author:
|
||||
author_name = getattr(tree.author, 'full_name', None) or tree.author.email
|
||||
|
||||
return TreeListResponse(
|
||||
id=tree.id,
|
||||
name=tree.name,
|
||||
description=tree.description,
|
||||
tree_type=tree.tree_type,
|
||||
category=tree.category,
|
||||
category_id=tree.category_id,
|
||||
category_info=category_info,
|
||||
tags=tree.tag_names,
|
||||
author_id=tree.author_id,
|
||||
author_name=author_name,
|
||||
account_id=tree.account_id,
|
||||
is_active=tree.is_active,
|
||||
is_public=tree.is_public,
|
||||
is_default=tree.is_default,
|
||||
visibility=tree.visibility,
|
||||
status=tree.status,
|
||||
version=tree.version,
|
||||
usage_count=tree.usage_count,
|
||||
created_at=tree.created_at,
|
||||
updated_at=tree.updated_at
|
||||
)
|
||||
```
|
||||
|
||||
**Step 4: Add `visibility` query param to `list_trees`**
|
||||
|
||||
In the function signature, add:
|
||||
```python
|
||||
visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
|
||||
```
|
||||
|
||||
In the filter section (after the `is_public` filter block):
|
||||
```python
|
||||
if visibility:
|
||||
query = query.where(Tree.visibility == visibility)
|
||||
```
|
||||
|
||||
**Step 5: Write tests**
|
||||
|
||||
In `backend/tests/test_trees.py`, add a new test class `TestVisibilityFilter`:
|
||||
|
||||
```python
|
||||
class TestVisibilityFilter:
|
||||
"""Test that visibility filtering works correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_tree_only_visible_to_author(
|
||||
self, client: AsyncClient, auth_headers: dict, test_user: dict
|
||||
):
|
||||
"""A private tree should NOT appear in another user's list."""
|
||||
# Create a private tree as test_user
|
||||
tree_data = {
|
||||
"name": "Private Flow",
|
||||
"tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
|
||||
}
|
||||
create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
|
||||
assert create_resp.status_code == 201
|
||||
tree_id = create_resp.json()["id"]
|
||||
|
||||
# Set visibility to private
|
||||
vis_resp = await client.patch(
|
||||
f"/api/v1/trees/{tree_id}/visibility",
|
||||
json={"visibility": "private"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert vis_resp.status_code == 200
|
||||
|
||||
# Verify it appears for the author
|
||||
list_resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert list_resp.status_code == 200
|
||||
ids = [t["id"] for t in list_resp.json()]
|
||||
assert tree_id in ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visibility_query_param_filters_correctly(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""?visibility=public should only return public trees."""
|
||||
resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
for tree in trees:
|
||||
assert tree["visibility"] == "public"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_author_name_present_in_list_response(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""TreeListResponse should include author_name."""
|
||||
resp = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
trees = resp.json()
|
||||
assert len(trees) >= 1
|
||||
# author_name should be present (may be None for system trees)
|
||||
assert "author_name" in trees[0]
|
||||
```
|
||||
|
||||
**Step 6: Run tests**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py::TestVisibilityFilter -v --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all 3 new tests pass.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/tree.py backend/app/api/endpoints/trees.py backend/tests/test_trees.py
|
||||
git commit -m "feat: add visibility filter param and author_name to tree list endpoint
|
||||
|
||||
GET /trees now accepts ?visibility=private|team|link|public to scope results.
|
||||
TreeListResponse includes author_name (full_name or email) and visibility.
|
||||
Author relationship eagerly loaded to avoid N+1 queries.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update frontend types and API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts`
|
||||
- Modify: `frontend/src/api/trees.ts`
|
||||
|
||||
### Background
|
||||
|
||||
`TreeListItem` needs `visibility` and `author_name`. The `trees.list()` API method needs a `visibility` parameter.
|
||||
|
||||
**Step 1: Add fields to `TreeListItem` in `frontend/src/types/tree.ts`**
|
||||
|
||||
After `author_id: string | null`, add:
|
||||
```typescript
|
||||
author_name: string | null
|
||||
visibility: 'private' | 'team' | 'link' | 'public'
|
||||
```
|
||||
|
||||
**Step 2: Add `visibility` to `TreeListParams` in `frontend/src/api/trees.ts`**
|
||||
|
||||
Find the params type/interface for `list()` and add:
|
||||
```typescript
|
||||
visibility?: 'private' | 'team' | 'link' | 'public'
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors. There may be type errors in `MyTreesPage.tsx` if it accesses `tree.visibility` — they'll be fixed in Task 4.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/tree.ts frontend/src/api/trees.ts
|
||||
git commit -m "feat: add visibility and author_name to TreeListItem type and list API params
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite `MyTreesPage` with tabs and fork button
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/MyTreesPage.tsx`
|
||||
|
||||
### Background
|
||||
|
||||
**Current state:**
|
||||
- Single list of `author_id=me` trees
|
||||
- Data loads on mount only (stale after navigating back from editor)
|
||||
- Has a "Create New" dropdown in the header
|
||||
- Has a fork badge on cards that have `parent_tree_id`
|
||||
|
||||
**New state:**
|
||||
- Four tabs: **My Flows** | **My Team** | **Public** | **All**
|
||||
- "My Team" tab hidden when `user.account_id` is null (solo user)
|
||||
- Data reloads when the active tab changes AND when the window regains focus (fixes stale data)
|
||||
- "Create New" button moves into the **My Flows** tab header area (only shown on that tab)
|
||||
- Cards on **Public** and **All** tabs (and **My Team** for other users' flows) show a **Fork** button
|
||||
- Fork button opens a minimal inline modal with optional reason field
|
||||
|
||||
### Tab → API call mapping
|
||||
|
||||
| Tab | `treesApi.list()` params |
|
||||
|-----|--------------------------|
|
||||
| My Flows | `{ author_id: user.id, sort_by: 'updated_at' }` |
|
||||
| My Team | `{ visibility: 'team', sort_by: 'updated_at' }` |
|
||||
| Public | `{ visibility: 'public', sort_by: 'usage_count' }` |
|
||||
| All | `{ sort_by: 'updated_at' }` |
|
||||
|
||||
**Step 1: Read the current `MyTreesPage.tsx` in full before editing.**
|
||||
|
||||
The file is at `frontend/src/pages/MyTreesPage.tsx`. Read it completely before making any changes.
|
||||
|
||||
**Step 2: Rewrite `MyTreesPage.tsx`**
|
||||
|
||||
Key structural changes:
|
||||
|
||||
```tsx
|
||||
type Tab = 'mine' | 'team' | 'public' | 'all'
|
||||
|
||||
export function MyTreesPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { canEditTree, canCreateTrees } = usePermissions()
|
||||
const navigate = useNavigate()
|
||||
const hasTeam = Boolean(user?.account_id)
|
||||
|
||||
// Active tab state — default to 'mine'
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
const [trees, setTrees] = useState<TreeWithStats[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
|
||||
// ... existing modal state (delete, share, AI builder)
|
||||
|
||||
// Load trees whenever the active tab changes
|
||||
useEffect(() => {
|
||||
loadTrees()
|
||||
}, [activeTab, user?.id])
|
||||
|
||||
// Reload on window focus (fixes stale data after navigating back from editor)
|
||||
useEffect(() => {
|
||||
const onFocus = () => loadTrees()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [activeTab, user?.id])
|
||||
|
||||
const loadTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: Parameters<typeof treesApi.list>[0] = {
|
||||
sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at',
|
||||
}
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') params.visibility = 'public'
|
||||
|
||||
const [userTrees, recentSessions] = await Promise.all([
|
||||
treesApi.list(params),
|
||||
activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
// Build lastUsed map (only for mine tab)
|
||||
const lastUsedMap = new Map<string, string>()
|
||||
for (const session of recentSessions) {
|
||||
const existing = lastUsedMap.get(session.tree_id)
|
||||
if (!existing || new Date(session.started_at) > new Date(existing)) {
|
||||
lastUsedMap.set(session.tree_id, session.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
setTrees(userTrees.map((tree) => ({
|
||||
...tree,
|
||||
lastUsed: lastUsedMap.get(tree.id),
|
||||
sessionCount: tree.usage_count ?? 0,
|
||||
})))
|
||||
} catch {
|
||||
toast.error('Failed to load flows')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFork = async () => {
|
||||
if (!forkTarget) return
|
||||
setIsForking(true)
|
||||
try {
|
||||
const forked = await treesApi.fork(forkTarget.id, {
|
||||
fork_reason: forkReason.trim() || undefined,
|
||||
})
|
||||
toast.success(`"${forked.name}" added to your flows`)
|
||||
setForkTarget(null)
|
||||
setForkReason('')
|
||||
// Switch to My Flows tab so they can see it
|
||||
setActiveTab('mine')
|
||||
} catch {
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForking(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tab bar UI** — render above the tree grid:
|
||||
```tsx
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Create button — only on My Flows tab */}
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<div className="ml-auto pb-1.5">
|
||||
{/* existing CreateMenu / AI builder button */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
Tabs array (built once, team tab conditionally included):
|
||||
```tsx
|
||||
const tabs = [
|
||||
{ id: 'mine' as Tab, label: 'My Flows' },
|
||||
...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
|
||||
{ id: 'public' as Tab, label: 'Public' },
|
||||
{ id: 'all' as Tab, label: 'All' },
|
||||
]
|
||||
```
|
||||
|
||||
**Fork button on cards** — shown when `tree.author_id !== user?.id` OR when on the Public tab:
|
||||
|
||||
```tsx
|
||||
{/* Show Fork button for flows you don't own */}
|
||||
{tree.author_id !== user?.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setForkTarget(tree); setForkReason('') }}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Fork
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**Fork confirmation modal** — simple inline modal (not a full-screen dialog):
|
||||
```tsx
|
||||
{forkTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-xl">
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">Fork this flow?</h3>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Creates a copy of “{forkTarget.name}” under your account that you can edit freely.
|
||||
</p>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Why are you forking? <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={forkReason}
|
||||
onChange={(e) => setForkReason(e.target.value)}
|
||||
placeholder="e.g. Adding Cisco Meraki steps for our network"
|
||||
maxLength={255}
|
||||
className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFork()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFork}
|
||||
disabled={isForking}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-gradient-brand py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{isForking ? 'Forking...' : 'Fork Flow'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForkTarget(null)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Author attribution on cards** — in the card header area, when `tree.author_id !== user?.id && tree.author_name`:
|
||||
```tsx
|
||||
{tree.author_id !== user?.id && tree.author_name && (
|
||||
<p className="text-[10px] font-label text-muted-foreground">
|
||||
by {tree.author_name}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
**Step 3: Verify build passes**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/MyTreesPage.tsx
|
||||
git commit -m "feat: add tabbed dashboard with My Flows/My Team/Public/All views and fork UI
|
||||
|
||||
- Tabs filter by visibility scope; My Team hidden for solo users
|
||||
- Data reloads on tab change and window focus (fixes stale-after-editor bug)
|
||||
- Create button moves into My Flows tab header
|
||||
- Fork button on flows not owned by current user; opens reason modal
|
||||
- Author attribution shown on cards from other users
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Keep `is_public` in sync when visibility changes
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/api/endpoints/trees.py` — the `update_tree_visibility` endpoint
|
||||
|
||||
### Background
|
||||
|
||||
The existing `PATCH /trees/{id}/visibility` endpoint sets the `visibility` column. But `is_public` is a separate boolean that some code may still read. We need to keep them in sync so `is_public = (visibility == 'public')`.
|
||||
|
||||
Find the visibility update endpoint (around line 1025–1077) and add the sync:
|
||||
|
||||
**Step 1: Add `is_public` sync in the visibility endpoint**
|
||||
|
||||
Locate the line that sets `tree.visibility = visibility_data.visibility` and add directly after it:
|
||||
```python
|
||||
tree.is_public = (visibility_data.visibility == 'public')
|
||||
```
|
||||
|
||||
Also do the same in `update_tree` (the PUT endpoint) — find where `is_public` is set from the update data and add a corresponding `visibility` update:
|
||||
```python
|
||||
# Keep visibility and is_public in sync
|
||||
if tree_data.is_public is not None:
|
||||
tree.is_public = tree_data.is_public
|
||||
if tree_data.is_public and tree.visibility not in ('public',):
|
||||
tree.visibility = 'public'
|
||||
elif not tree_data.is_public and tree.visibility == 'public':
|
||||
tree.visibility = 'team' # downgrade from public to team
|
||||
```
|
||||
|
||||
**Step 2: Run existing tests**
|
||||
|
||||
```bash
|
||||
cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/trees.py
|
||||
git commit -m "fix: keep is_public and visibility in sync on updates
|
||||
|
||||
When visibility changes to 'public', is_public=True. When it changes away
|
||||
from 'public', is_public=False. When is_public is set via TreeUpdate,
|
||||
visibility column is updated to match.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Push and verify
|
||||
|
||||
**Step 1: Push branch**
|
||||
|
||||
```bash
|
||||
git push
|
||||
```
|
||||
|
||||
**Step 2: Check PR CI**
|
||||
|
||||
```bash
|
||||
gh pr checks 88 2>&1 | head -20
|
||||
```
|
||||
|
||||
**Step 3: Manual smoke test checklist**
|
||||
|
||||
- [ ] Create a new AI flow → "Open in Editor" → publish → navigate back to dashboard. Flow should appear immediately (focus trigger reloads).
|
||||
- [ ] Dashboard has tabs: My Flows, My Team (if account), Public, All.
|
||||
- [ ] My Flows tab shows only flows you authored. Create button is here.
|
||||
- [ ] My Team tab shows team-visibility flows from your account (other team members' flows appear here).
|
||||
- [ ] Public tab shows `visibility='public'` flows from all users. Fork button visible on flows you don't own.
|
||||
- [ ] Fork a public flow → reason modal appears → confirm → toast "Added to your flows" → switches to My Flows tab → fork appears there.
|
||||
- [ ] Solo user (no account_id) sees no My Team tab.
|
||||
- [ ] Cards for other users' flows show "by [author name]" attribution.
|
||||
- [ ] Changing a flow's visibility to "private" via editor makes it disappear from team members' My Team tab (requires two user accounts to verify).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes for Claude
|
||||
|
||||
**Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/feat-ai-flow-builder`
|
||||
**Branch:** `frontend-standardization` (PR #88)
|
||||
**Run backend tests from:** `backend/venv/bin/python -m pytest ...` (worktrees share the main venv)
|
||||
**Frontend build:** `cd frontend && npm run build`
|
||||
|
||||
**Before Task 4:** Read `MyTreesPage.tsx` in FULL before making any edits. The file is ~410 lines and has important modal state, the AI builder button, delete/share handlers that must be preserved exactly.
|
||||
|
||||
**Task 4 note on `TreeWithStats`:** The existing interface extension adds `lastUsed` and `sessionCount`. Also add `parent_tree_id` and `parent_tree_name` which are already being used in the fork badge rendering. Keep those. Also add `visibility` and `author_name` since `TreeListItem` now includes them (inherited from the spread `...tree`).
|
||||
|
||||
**User model field name:** Check `backend/app/models/user.py` for the display name field — it may be `full_name`, `name`, or just `email`. Use `getattr(tree.author, 'full_name', None) or tree.author.email` as a safe fallback.
|
||||
@@ -11,8 +11,8 @@ export const apiClient = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Global error handler - shows toast for common API errors
|
||||
// Pages can still catch errors explicitly if they need custom handling
|
||||
// Global error handler for shared cases only.
|
||||
// By convention, 4xx errors are handled at the page/component level.
|
||||
function handleGlobalError(error: AxiosError) {
|
||||
// Network error (no response from server)
|
||||
if (!error.response) {
|
||||
@@ -43,9 +43,8 @@ function handleGlobalError(error: AxiosError) {
|
||||
return
|
||||
}
|
||||
|
||||
// Client errors (4xx) — don't toast globally.
|
||||
// Pages handle their own 4xx errors (permission checks, validation, not-found)
|
||||
// and many are caught silently. Global toasts here cause noisy duplicates.
|
||||
// Client errors (4xx) remain page-owned to avoid duplicate/noisy toasts.
|
||||
// Global handling only covers 401/429/5xx.
|
||||
if (status >= 400 && status < 500) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,25 +1,2 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex-shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
export { PageHeader } from '@/components/common/PageHeader'
|
||||
export { PageHeader as default } from '@/components/common/PageHeader'
|
||||
|
||||
@@ -37,6 +37,11 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
|
||||
// Auto-trigger scaffold after conversation starts (ref prevents double-fire)
|
||||
const hasTriggeredScaffold = useRef(false)
|
||||
useEffect(() => {
|
||||
// Reset guard when wizard resets to foundation (Start Over or close)
|
||||
if (phase === 'foundation') {
|
||||
hasTriggeredScaffold.current = false
|
||||
return
|
||||
}
|
||||
if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
|
||||
hasTriggeredScaffold.current = true
|
||||
scaffold()
|
||||
@@ -48,27 +53,48 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleOpenInEditor = async () => {
|
||||
if (!assembledTree) return
|
||||
const createTree = async () => {
|
||||
if (!assembledTree) return null
|
||||
try {
|
||||
const tree = await treesApi.create({
|
||||
return await treesApi.create({
|
||||
name: assembledTree.suggested_name,
|
||||
description: assembledTree.suggested_description,
|
||||
tree_structure: assembledTree.tree_structure,
|
||||
tree_type: metadata.flow_type,
|
||||
status: 'draft',
|
||||
})
|
||||
handleClose()
|
||||
const editorPath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/edit`
|
||||
: `/trees/${tree.id}/edit`
|
||||
navigate(editorPath)
|
||||
} catch {
|
||||
toast.error('Failed to create flow. Please try again.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenInEditor = async () => {
|
||||
const tree = await createTree()
|
||||
if (!tree) return
|
||||
handleClose()
|
||||
const editorPath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/edit`
|
||||
: `/trees/${tree.id}/edit`
|
||||
navigate(editorPath)
|
||||
}
|
||||
|
||||
const handleStartFlow = async () => {
|
||||
const tree = await createTree()
|
||||
if (!tree) return
|
||||
handleClose()
|
||||
const navigatePath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/navigate`
|
||||
: `/trees/${tree.id}/navigate`
|
||||
navigate(navigatePath)
|
||||
}
|
||||
|
||||
const handleBuildAnother = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (phase) {
|
||||
case 'foundation':
|
||||
@@ -102,7 +128,11 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
|
||||
{phase === 'generating' && <GeneratingAnimation />}
|
||||
{phase === 'detailing' && <BranchDetailView />}
|
||||
{phase === 'reviewing' && (
|
||||
<TreePreviewCard onOpenInEditor={handleOpenInEditor} />
|
||||
<TreePreviewCard
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onStartFlow={handleStartFlow}
|
||||
onBuildAnother={handleBuildAnother}
|
||||
/>
|
||||
)}
|
||||
{phase === 'error' && <ErrorView />}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft } from 'lucide-react'
|
||||
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft, Zap, Square } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { GeneratingAnimation } from './GeneratingAnimation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,9 @@ export function BranchDetailView() {
|
||||
error,
|
||||
phase,
|
||||
setError,
|
||||
isGeneratingAll,
|
||||
generateAllBranchDetails,
|
||||
cancelGenerateAll,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
const viewingIndex = currentBranchIndex
|
||||
@@ -32,11 +35,23 @@ export function BranchDetailView() {
|
||||
}
|
||||
|
||||
if (phase === 'generating' && isLoading) {
|
||||
return <GeneratingAnimation />
|
||||
return (
|
||||
<GeneratingAnimation
|
||||
branchContext={
|
||||
isGeneratingAll
|
||||
? {
|
||||
current: selectedBranches.filter((b) => b.steps).length + 1,
|
||||
total: selectedBranches.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* Content area */}
|
||||
<div className="space-y-4">
|
||||
{/* Branch tabs */}
|
||||
@@ -83,7 +98,7 @@ export function BranchDetailView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
@@ -92,21 +107,52 @@ export function BranchDetailView() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-accent/20 py-8">
|
||||
<div className="flex flex-col items-center gap-4 rounded-lg border border-dashed border-border bg-accent/20 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate AI detail for this branch
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
||||
{/* Generate All — primary action, shown when multiple branches remain */}
|
||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
||||
isGeneratingAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelGenerateAll}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-400/10 px-5 py-2.5 text-sm font-medium text-red-400 hover:bg-red-400/20"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Generating
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateAllBranchDetails}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
Generate All {selectedBranches.filter((b) => !b.steps).length} Branches
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Divider + secondary actions */}
|
||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="h-px w-8 bg-border" />
|
||||
or
|
||||
<div className="h-px w-8 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
isLoading ? 'cursor-not-allowed opacity-50' : 'hover:opacity-90'
|
||||
)}
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Generate Detail
|
||||
Generate this branch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -115,9 +161,10 @@ export function BranchDetailView() {
|
||||
setViewingIndex(viewingIndex + 1)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
disabled={isGeneratingAll}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
<SkipForward className="h-3 w-3" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, Plus, X, Pencil, Check } from 'lucide-react'
|
||||
import { GripVertical, Plus, X, Pencil, Check, RefreshCw } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIBranch } from '@/types'
|
||||
@@ -10,6 +10,8 @@ export function BranchSelector() {
|
||||
selectedBranches,
|
||||
selectBranches,
|
||||
setPhase,
|
||||
scaffold,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
@@ -73,10 +75,20 @@ export function BranchSelector() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaffold()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
title="Generate new suggestions"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branch list */}
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
const MESSAGES = [
|
||||
'Analyzing your flow requirements...',
|
||||
'Building decision paths...',
|
||||
'Generating troubleshooting logic...',
|
||||
'Crafting resolution steps...',
|
||||
'Structuring the flow...',
|
||||
]
|
||||
|
||||
export function GeneratingAnimation() {
|
||||
const [messageIndex, setMessageIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % MESSAGES.length)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
interface GeneratingAnimationProps {
|
||||
branchContext?: { current: number; total: number }
|
||||
}
|
||||
|
||||
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-primary" />
|
||||
<Sparkles className="absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">
|
||||
{MESSAGES[messageIndex]}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
{/* Spinner */}
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
|
||||
{/* Branch context (Generate All mode) */}
|
||||
{branchContext ? (
|
||||
<>
|
||||
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
|
||||
Branch {branchContext.current} of {branchContext.total}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Generating branch detail...</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Generating...</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw } from 'lucide-react'
|
||||
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw, Play } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreePreviewCardProps {
|
||||
onOpenInEditor: () => void
|
||||
onStartFlow: () => void
|
||||
onBuildAnother: () => void
|
||||
}
|
||||
|
||||
export function TreePreviewCard({ onOpenInEditor }: TreePreviewCardProps) {
|
||||
const { assembledTree, reset, isLoading } = useAIFlowBuilderStore()
|
||||
export function TreePreviewCard({ onOpenInEditor, onStartFlow, onBuildAnother }: TreePreviewCardProps) {
|
||||
const { assembledTree, isLoading } = useAIFlowBuilderStore()
|
||||
|
||||
if (!assembledTree) return null
|
||||
|
||||
@@ -58,27 +60,38 @@ export function TreePreviewCard({ onOpenInEditor }: TreePreviewCardProps) {
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInEditor}
|
||||
onClick={onStartFlow}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90'
|
||||
'flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Start Over
|
||||
<Play className="h-4 w-4" />
|
||||
Start Flow
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInEditor}
|
||||
disabled={isLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuildAnother}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Build Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
43
frontend/src/components/common/PageHeader.tsx
Normal file
43
frontend/src/components/common/PageHeader.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
action,
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4', className)}>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon && <div className="shrink-0">{icon}</div>}
|
||||
<div>
|
||||
<h1 className={cn('text-2xl font-bold font-heading text-foreground', titleClassName)}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className={cn('mt-1 text-sm text-muted-foreground', descriptionClassName)}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHeader
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -55,8 +55,7 @@ export function AppLayout() {
|
||||
{ path: '/sessions', label: 'Sessions', icon: Clock },
|
||||
{ path: '/shares', label: 'Exports', icon: FileText },
|
||||
{ path: '/step-library', label: 'Step Library', icon: Bookmark },
|
||||
{ path: '/account', label: 'Team', icon: Users },
|
||||
{ path: '/account', label: 'Settings', icon: Settings },
|
||||
{ path: '/account', label: 'Account', icon: Settings },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions, type EffectiveRole } from '@/hooks/usePermissions'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
requiredRole?: EffectiveRole
|
||||
@@ -15,7 +16,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner className="border-t-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { targetListsApi, batchLaunchApi } from '@/api'
|
||||
import type { TargetList, TargetEntry } from '@/types'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
interface BatchLaunchModalProps {
|
||||
treeId: string
|
||||
@@ -127,7 +128,7 @@ export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: Batc
|
||||
<div className="space-y-2">
|
||||
{savedLists === null ? (
|
||||
<div className="flex h-24 items-center justify-center">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<Spinner size="sm" className="border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : savedLists.length === 0 ? (
|
||||
<p className="text-[0.875rem] text-muted-foreground">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
interface ShareSessionModalProps {
|
||||
sessionId: string
|
||||
@@ -406,7 +407,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }:
|
||||
{/* Loading state */}
|
||||
{isLoadingShares && shares.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
<Spinner size="sm" className="h-5 w-5 border-t-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { StepForm } from './StepForm'
|
||||
import { StepLibraryBrowser } from './StepLibraryBrowser'
|
||||
import type { Step, StepCreate } from '@/types/step'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
export interface CustomStepDraft {
|
||||
title: string
|
||||
@@ -134,7 +135,7 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" />
|
||||
<Spinner className="border-t-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Creating step...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NodeEditorPanel } from './NodeEditorPanel'
|
||||
import { MetadataSidePanel } from './MetadataSidePanel'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
// Lazy load CodeModeEditor (Monaco is ~2MB)
|
||||
const CodeModeEditor = lazy(() =>
|
||||
@@ -46,7 +47,7 @@ export function TreeEditorLayout({
|
||||
)}>
|
||||
<Suspense fallback={
|
||||
<div className="flex h-full items-center justify-center bg-card">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
|
||||
</div>
|
||||
}>
|
||||
<CodeModeEditor />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createCompletionProvider } from './resolutionFlowCompletions'
|
||||
import { CodeModeToolbar } from './CodeModeToolbar'
|
||||
import { SyntaxHelpPanel } from './SyntaxHelpPanel'
|
||||
import { setMonacoEditor } from './monacoEditorRef'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
|
||||
export function CodeModeEditor() {
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
|
||||
@@ -167,7 +168,7 @@ export function CodeModeEditor() {
|
||||
onMount={handleEditorDidMount}
|
||||
loading={
|
||||
<div className="flex h-full items-center justify-center bg-card">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
<Spinner size="sm" className="h-6 w-6 border-t-foreground" />
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
|
||||
@@ -138,12 +138,10 @@ export function AccountSettingsPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -152,7 +150,7 @@ export function AccountSettingsPage() {
|
||||
const sub = subscription?.subscription
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="h-8 w-8 text-muted-foreground" />
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ForgotPasswordPage() {
|
||||
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground tracking-tight">
|
||||
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -5,6 +5,9 @@ import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
|
||||
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Tree, MaintenanceSchedule, Session } from '@/types'
|
||||
@@ -64,12 +67,29 @@ export default function MaintenanceFlowDetailPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<Spinner size="sm" className="h-6 w-6 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!tree) return null
|
||||
if (!tree) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
title="Maintenance flow not found"
|
||||
description="This flow is unavailable or you do not have access."
|
||||
action={(
|
||||
<button
|
||||
onClick={() => navigate('/trees?type=maintenance')}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Back to Maintenance Flows
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group sessions by batch_id for run history
|
||||
const batchMap = new Map<string, Session[]>()
|
||||
@@ -81,43 +101,42 @@ export default function MaintenanceFlowDetailPage() {
|
||||
const batches = Array.from(batchMap.entries()).slice(0, 10)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className="container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
title={tree.name}
|
||||
description={tree.description || undefined}
|
||||
icon={(
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Wrench className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">{tree.name}</h1>
|
||||
{tree.description && (
|
||||
<p className="text-[0.8125rem] text-muted-foreground">{tree.description}</p>
|
||||
)}
|
||||
)}
|
||||
titleClassName="text-xl font-semibold"
|
||||
action={(
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/flows/${id}/edit`)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Edit Flow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/flows/${id}/navigate`)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Batch Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/flows/${id}/edit`)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Edit Flow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/flows/${id}/navigate`)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBatchModal(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Batch Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Schedule Panel */}
|
||||
<div className="rounded-xl border border-border bg-card p-5">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
@@ -54,8 +55,11 @@ export default function MyAnalyticsPage() {
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-muted-foreground">Failed to load analytics data.</p>
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
title="Analytics unavailable"
|
||||
description="Failed to load analytics data. Please try again."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, Loader2, Star, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Search, Loader2, Star, ChevronLeft, ChevronRight, GitBranch } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { TreeListItem, TreeFilters } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
@@ -21,6 +21,7 @@ import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
@@ -66,6 +67,16 @@ export function QuickStartPage() {
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Tab state
|
||||
type Tab = 'mine' | 'team' | 'public' | 'all'
|
||||
const hasTeam = Boolean(user?.account_id)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('mine')
|
||||
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
const [forkReason, setForkReason] = useState('')
|
||||
const [isForking, setIsForking] = useState(false)
|
||||
|
||||
// Pin store
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const pinnedIsLoading = usePinnedFlowsStore((s) => s.isLoading)
|
||||
@@ -102,34 +113,29 @@ export function QuickStartPage() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Load my flows when page/size or user changes
|
||||
useEffect(() => {
|
||||
// Load flows — tab-aware
|
||||
const loadFlows = useCallback(async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
const loadFlows = async () => {
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
try {
|
||||
if (pageSize === 'all') {
|
||||
// Fetch in chunks of 100, max 500
|
||||
let allItems: TreeListItem[] = []
|
||||
let skip = 0
|
||||
const CHUNK = 100
|
||||
const MAX = 500
|
||||
|
||||
while (true) {
|
||||
const chunk = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: CHUNK,
|
||||
skip,
|
||||
})
|
||||
const params: TreeFilters = { sort_by: 'updated_at', limit: CHUNK, skip }
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') { params.visibility = 'public'; params.sort_by = 'usage_count' }
|
||||
|
||||
const chunk = await treesApi.list(params)
|
||||
allItems = [...allItems, ...chunk]
|
||||
if (chunk.length < CHUNK || allItems.length >= MAX) {
|
||||
if (allItems.length >= MAX) {
|
||||
allItems = allItems.slice(0, MAX)
|
||||
setAllFlowsCeiling(true)
|
||||
}
|
||||
if (allItems.length >= MAX) { allItems = allItems.slice(0, MAX); setAllFlowsCeiling(true) }
|
||||
break
|
||||
}
|
||||
skip += CHUNK
|
||||
@@ -138,20 +144,34 @@ export function QuickStartPage() {
|
||||
setHasNextPage(false)
|
||||
} else {
|
||||
const numSize = pageSize as number
|
||||
const response = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
const params: TreeFilters = {
|
||||
sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at',
|
||||
limit: numSize + 1,
|
||||
skip: (page - 1) * numSize,
|
||||
})
|
||||
}
|
||||
if (activeTab === 'mine') params.author_id = user.id
|
||||
if (activeTab === 'team') params.visibility = 'team'
|
||||
if (activeTab === 'public') params.visibility = 'public'
|
||||
|
||||
const response = await treesApi.list(params)
|
||||
setHasNextPage(response.length > numSize)
|
||||
setMyFlows(response.slice(0, numSize))
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setIsLoadingFlows(false)
|
||||
}
|
||||
}, [user?.id, page, pageSize, activeTab])
|
||||
|
||||
loadFlows().catch(() => setIsLoadingFlows(false))
|
||||
}, [user?.id, page, pageSize])
|
||||
useEffect(() => { loadFlows() }, [loadFlows])
|
||||
|
||||
// Reload on window focus (fixes stale data after returning from editor)
|
||||
useEffect(() => {
|
||||
const onFocus = () => loadFlows()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [loadFlows])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
@@ -219,9 +239,35 @@ export function QuickStartPage() {
|
||||
const handleTagClick = () => {} // Not used on dashboard
|
||||
const handleFolderCreated = () => {} // Not used on dashboard
|
||||
|
||||
const handleFork = async () => {
|
||||
if (!forkTarget) return
|
||||
setIsForking(true)
|
||||
try {
|
||||
const forked = await treesApi.fork(forkTarget.id, {
|
||||
fork_reason: forkReason.trim() || undefined,
|
||||
})
|
||||
toast.success(`"${forked.name}" added to your flows`)
|
||||
setForkTarget(null)
|
||||
setForkReason('')
|
||||
setActiveTab('mine')
|
||||
} catch {
|
||||
toast.error('Failed to fork flow')
|
||||
} finally {
|
||||
setIsForking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Page size options
|
||||
const pageSizeOptions: (number | 'all')[] = [10, 25, 50, 'all']
|
||||
|
||||
// Tabs
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'mine', label: 'My Flows' },
|
||||
...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
|
||||
{ id: 'public', label: 'Public' },
|
||||
{ id: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -234,14 +280,6 @@ export function QuickStartPage() {
|
||||
Welcome back. Here's what's happening with your flows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
@@ -354,11 +392,31 @@ export function QuickStartPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Flows Section */}
|
||||
{/* My Flows Section — tabbed */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">My Flows</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-3 flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1) }}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2 pb-1.5">
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,8 +429,16 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">You haven't created any flows yet.</p>
|
||||
{canCreateTrees && (
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{activeTab === 'mine'
|
||||
? "You haven't created any flows yet."
|
||||
: activeTab === 'team'
|
||||
? 'No team flows found.'
|
||||
: activeTab === 'public'
|
||||
? 'No public flows found.'
|
||||
: 'No flows found.'}
|
||||
</p>
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
@@ -497,6 +563,48 @@ export function QuickStartPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fork Modal */}
|
||||
{forkTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-5 shadow-xl">
|
||||
<h3 className="mb-1 text-sm font-semibold text-foreground">Fork this flow?</h3>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Creates a copy of “{forkTarget.name}” under your account that you can edit freely.
|
||||
</p>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Why are you forking? <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={forkReason}
|
||||
onChange={(e) => setForkReason(e.target.value)}
|
||||
placeholder="e.g. Adding Cisco Meraki steps for our network"
|
||||
maxLength={255}
|
||||
className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFork()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFork}
|
||||
disabled={isForking}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-gradient-brand py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{isForking ? 'Forking...' : 'Fork Flow'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForkTarget(null)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
|
||||
const CHART_COLORS = {
|
||||
@@ -46,6 +48,12 @@ export default function TeamAnalyticsPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [period, isAccountOwner, isSuperAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAccountOwner && !isSuperAdmin) {
|
||||
toast.info('Viewing your personal analytics', { id: 'analytics-redirect' })
|
||||
}
|
||||
}, [isAccountOwner, isSuperAdmin])
|
||||
|
||||
if (!isAccountOwner && !isSuperAdmin) {
|
||||
return <Navigate to="/analytics/me" replace />
|
||||
}
|
||||
@@ -60,8 +68,11 @@ export default function TeamAnalyticsPage() {
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-muted-foreground">Failed to load analytics data.</p>
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
title="Analytics unavailable"
|
||||
description="Failed to load analytics data. Please try again."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ export function TreeEditorPage() {
|
||||
{/* Publish */}
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={isSaving || !isDirty || hasBlockingErrors}
|
||||
disabled={isSaving || hasBlockingErrors}
|
||||
title={hasBlockingErrors ? 'Fix validation errors before publishing (Ctrl+S when no errors)' : 'Publish tree (Ctrl+S when no errors)'}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
|
||||
@@ -21,6 +21,8 @@ import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function TreeLibraryPage() {
|
||||
@@ -465,13 +467,17 @@ export function TreeLibraryPage() {
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" />
|
||||
<Spinner />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
No flows found.{' '}
|
||||
{(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'}
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No flows found"
|
||||
description={
|
||||
(searchQuery || hasActiveFilters)
|
||||
? 'Try adjusting your filters.'
|
||||
: 'Create your first flow to get started.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{treeLibraryView === 'grid' && (
|
||||
|
||||
@@ -795,7 +795,7 @@ export function TreeNavigationPage() {
|
||||
{index < 9 && (
|
||||
selectingOption === option.id ? (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
<Spinner size="sm" className="h-4 w-4 border-t-foreground" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -4,6 +4,10 @@ import { targetListsApi } from '@/api'
|
||||
import type { TargetList, TargetListCreate, TargetEntry } from '@/types'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
|
||||
export default function TargetListsPage() {
|
||||
const [lists, setLists] = useState<TargetList[]>([])
|
||||
@@ -103,34 +107,31 @@ export default function TargetListsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Target Lists</h1>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Saved server lists for maintenance flow batch launching
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openEditor()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New List
|
||||
</button>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Target Lists"
|
||||
titleClassName="text-xl font-semibold"
|
||||
description="Saved server lists for maintenance flow batch launching"
|
||||
action={(
|
||||
<button
|
||||
onClick={() => openEditor()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New List
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<Spinner size="sm" className="h-5 w-5 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-12 text-center">
|
||||
<Server className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="font-medium text-foreground">No target lists yet</p>
|
||||
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
||||
Create lists of servers to reuse across maintenance runs
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Server className="h-10 w-10" />}
|
||||
title="No target lists yet"
|
||||
description="Create lists of servers to reuse across maintenance runs."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{lists.map(list => (
|
||||
@@ -172,68 +173,68 @@ export default function TargetListsPage() {
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
{showEditor && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl">
|
||||
<h2 className="mb-4 text-base font-semibold text-foreground">
|
||||
{editingList ? 'Edit Target List' : 'New Target List'}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorName}
|
||||
onChange={e => setEditorName(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="e.g. RDS Farm A"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Description (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorDescription}
|
||||
onChange={e => setEditorDescription(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="e.g. Production RDS servers"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Targets — one per line (add notes after #)
|
||||
</label>
|
||||
<textarea
|
||||
value={editorTargets}
|
||||
onChange={e => setEditorTargets(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowEditor(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving\u2026' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={showEditor}
|
||||
onClose={() => setShowEditor(false)}
|
||||
title={editingList ? 'Edit Target List' : 'New Target List'}
|
||||
size="md"
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowEditor(false)}
|
||||
className="rounded-lg border border-border px-4 py-2 text-[0.875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving\u2026' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorName}
|
||||
onChange={e => setEditorName(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="e.g. RDS Farm A"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Description (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorDescription}
|
||||
onChange={e => setEditorDescription(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder="e.g. Production RDS servers"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Targets — one per line (add notes after #)
|
||||
</label>
|
||||
<textarea
|
||||
value={editorTargets}
|
||||
onChange={e => setEditorTargets(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
placeholder={"RDS-01 # 192.168.1.10\nRDS-02\nRDS-03 # Backup server"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{deleteTarget && (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Trash2, Pencil, FolderTree } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
import api from '@/api/client'
|
||||
|
||||
interface TeamCategory {
|
||||
@@ -80,17 +81,17 @@ export function TeamCategoriesPage() {
|
||||
const inputCn = cn('w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground', 'placeholder:text-muted-foreground focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20')
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground">Team Categories</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Manage tree categories for your team</p>
|
||||
</div>
|
||||
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Team Categories"
|
||||
description="Manage tree categories for your team"
|
||||
action={(
|
||||
<button onClick={() => setCreateOpen(true)} className={cn('flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium', 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Category
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket, KeyRound, Copy, Check, Archive, ArchiveRestore, Trash2 } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/admin'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -177,14 +179,25 @@ export function UserDetailPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
<Spinner className="border-t-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-20 text-center text-muted-foreground">User not found</div>
|
||||
<EmptyState
|
||||
title="User not found"
|
||||
description="This user may have been removed or is unavailable."
|
||||
action={(
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Back to Users
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,7 +215,7 @@ export function UserDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
<h1 className="text-xl font-heading font-semibold text-foreground">
|
||||
{user.full_name || user.email}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AIFlowBuilderState {
|
||||
|
||||
// UI state
|
||||
isLoading: boolean
|
||||
isGeneratingAll: boolean
|
||||
stopGeneratingAll: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
@@ -39,6 +41,8 @@ interface AIFlowBuilderState {
|
||||
selectBranches: (branches: AIBranch[]) => void
|
||||
generateBranchDetail: (branchName: string) => Promise<void>
|
||||
assemble: () => Promise<void>
|
||||
generateAllBranchDetails: () => Promise<void>
|
||||
cancelGenerateAll: () => void
|
||||
reset: () => void
|
||||
setPhase: (phase: AIWizardPhase) => void
|
||||
setError: (error: string | null) => void
|
||||
@@ -62,6 +66,8 @@ export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) =>
|
||||
assembledTree: null,
|
||||
quota: null,
|
||||
isLoading: false,
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
error: null,
|
||||
|
||||
loadQuota: async () => {
|
||||
@@ -175,6 +181,30 @@ export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
generateAllBranchDetails: async () => {
|
||||
const { selectedBranches, generateBranchDetail } = get()
|
||||
const undetailed = selectedBranches.filter((b) => !b.steps)
|
||||
if (undetailed.length === 0) return
|
||||
|
||||
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
|
||||
|
||||
for (const branch of undetailed) {
|
||||
if (get().stopGeneratingAll) break
|
||||
// Set currentBranchIndex so tabs show the active branch
|
||||
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
if (idx !== -1) set({ currentBranchIndex: idx })
|
||||
await generateBranchDetail(branch.name)
|
||||
// If generateBranchDetail set phase to 'error', stop
|
||||
if (get().phase === 'error') break
|
||||
}
|
||||
|
||||
set({ isGeneratingAll: false })
|
||||
},
|
||||
|
||||
cancelGenerateAll: () => {
|
||||
set({ stopGeneratingAll: true })
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
phase: 'foundation',
|
||||
@@ -185,6 +215,8 @@ export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) =>
|
||||
currentBranchIndex: 0,
|
||||
assembledTree: null,
|
||||
isLoading: false,
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -164,6 +164,7 @@ export interface TreeListItem {
|
||||
category_info: CategoryInfo | null
|
||||
tags: string[]
|
||||
author_id: string | null
|
||||
author_name: string | null
|
||||
account_id: string | null
|
||||
is_active: boolean
|
||||
is_public: boolean
|
||||
@@ -173,6 +174,7 @@ export interface TreeListItem {
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
visibility: 'private' | 'team' | 'link' | 'public'
|
||||
}
|
||||
|
||||
export interface TreeCreate {
|
||||
@@ -215,6 +217,7 @@ export interface TreeFilters {
|
||||
is_active?: boolean
|
||||
author_id?: string
|
||||
is_public?: boolean
|
||||
visibility?: 'private' | 'team' | 'link' | 'public'
|
||||
sort_by?: 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||
skip?: number
|
||||
limit?: number
|
||||
|
||||
@@ -8,8 +8,8 @@ export interface User {
|
||||
is_super_admin: boolean
|
||||
is_active: boolean
|
||||
must_change_password: boolean
|
||||
account_id: string
|
||||
account_role: 'owner' | 'engineer' | 'viewer'
|
||||
account_id: string | null
|
||||
account_role: 'owner' | 'engineer' | 'viewer' | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user