diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 743106f8..fb63f7e3 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -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, diff --git a/backend/app/core/ai_tree_generator_service.py b/backend/app/core/ai_tree_generator_service.py index 01db3bf9..4d40e257 100644 --- a/backend/app/core/ai_tree_generator_service.py +++ b/backend/app/core/ai_tree_generator_service.py @@ -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) diff --git a/backend/app/core/ai_tree_validator.py b/backend/app/core/ai_tree_validator.py index 7e1df9a9..37941066 100644 --- a/backend/app/core/ai_tree_validator.py +++ b/backend/app/core/ai_tree_validator.py @@ -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( diff --git a/backend/app/core/filters.py b/backend/app/core/filters.py index 6e9b587d..005e269d 100644 --- a/backend/app/core/filters.py +++ b/backend/app/core/filters.py @@ -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) diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index c19c5f70..dc9cf116 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index b8983cb6..5b580e67 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/tests/test_ai_tree_validator.py b/backend/tests/test_ai_tree_validator.py index 1a9d7186..f8f3f4d7 100644 --- a/backend/tests/test_ai_tree_validator.py +++ b/backend/tests/test_ai_tree_validator.py @@ -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" diff --git a/backend/tests/test_trees.py b/backend/tests/test_trees.py index 79347246..300a50f6 100644 --- a/backend/tests/test_trees.py +++ b/backend/tests/test_trees.py @@ -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] diff --git a/docs/plans/2026-02-24-ai-builder-ux-improvements-plan.md b/docs/plans/2026-02-24-ai-builder-ux-improvements-plan.md new file mode 100644 index 00000000..80e0c9d9 --- /dev/null +++ b/docs/plans/2026-02-24-ai-builder-ux-improvements-plan.md @@ -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 ( +
+ {/* Spinner */} +
+ + {/* Branch context (Generate All mode) */} + {branchContext && ( +

+ Branch {branchContext.current} of {branchContext.total} +

+ )} + + {/* Rotating message */} +

+ {MESSAGES[messageIndex]} +

+
+ ) +} +``` + +**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 +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 `
` 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 ( +
+ + {undetailedCount} branch{undetailedCount !== 1 ? 'es' : ''} need detail + + {isGeneratingAll ? ( + + ) : ( + + )} +
+ ) +})()} +``` + +**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 ( + 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 diff --git a/docs/plans/2026-02-24-ai-builder-ux-improvements.md b/docs/plans/2026-02-24-ai-builder-ux-improvements.md new file mode 100644 index 00000000..21094e64 --- /dev/null +++ b/docs/plans/2026-02-24-ai-builder-ux-improvements.md @@ -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` — 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) diff --git a/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md new file mode 100644 index 00000000..92b77a71 --- /dev/null +++ b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md @@ -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 " +``` + +--- + +## 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=` (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 " +``` + +--- + +## 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 " +``` + +--- + +## 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('mine') + const [trees, setTrees] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + // Fork modal state + const [forkTarget, setForkTarget] = useState(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[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() + 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 */} +
+ {tabs.map((tab) => ( + + ))} + + {/* Create button — only on My Flows tab */} + {activeTab === 'mine' && canCreateTrees && ( +
+ {/* existing CreateMenu / AI builder button */} +
+ )} +
+``` + +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 && ( + +)} +``` + +**Fork confirmation modal** — simple inline modal (not a full-screen dialog): +```tsx +{forkTarget && ( +
+
+

Fork this flow?

+

+ Creates a copy of “{forkTarget.name}” under your account that you can edit freely. +

+ + 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()} + /> +
+ + +
+
+
+)} +``` + +**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 && ( +

+ by {tree.author_name} +

+)} +``` + +**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 " +``` + +--- + +## 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 " +``` + +--- + +## 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. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 07b6bd36..fbc5b251 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 } diff --git a/frontend/src/components/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx index d282bcdc..8856ac13 100644 --- a/frontend/src/components/admin/PageHeader.tsx +++ b/frontend/src/components/admin/PageHeader.tsx @@ -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 ( -
-
-

{title}

- {description && ( -

{description}

- )} -
- {action &&
{action}
} -
- ) -} - -export default PageHeader +export { PageHeader } from '@/components/common/PageHeader' +export { PageHeader as default } from '@/components/common/PageHeader' diff --git a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx index 939876d2..49013055 100644 --- a/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx +++ b/frontend/src/components/ai-builder/AIFlowBuilderModal.tsx @@ -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' && } {phase === 'detailing' && } {phase === 'reviewing' && ( - + )} {phase === 'error' && } diff --git a/frontend/src/components/ai-builder/BranchDetailView.tsx b/frontend/src/components/ai-builder/BranchDetailView.tsx index 24ef854a..0f004a61 100644 --- a/frontend/src/components/ai-builder/BranchDetailView.tsx +++ b/frontend/src/components/ai-builder/BranchDetailView.tsx @@ -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 + return ( + b.steps).length + 1, + total: selectedBranches.length, + } + : undefined + } + /> + ) } return (
+ {/* Content area */}
{/* Branch tabs */} @@ -83,7 +98,7 @@ export function BranchDetailView() {
) : ( -
+

Generate AI detail for this branch

-
+ + {/* Generate All — primary action, shown when multiple branches remain */} + {selectedBranches.filter((b) => !b.steps).length > 1 && ( + isGeneratingAll ? ( + + ) : ( + + ) + )} + + {/* Divider + secondary actions */} + {selectedBranches.filter((b) => !b.steps).length > 1 && ( +
+
+ or +
+
+ )} + +
diff --git a/frontend/src/components/ai-builder/BranchSelector.tsx b/frontend/src/components/ai-builder/BranchSelector.tsx index 458f5b80..f939c024 100644 --- a/frontend/src/components/ai-builder/BranchSelector.tsx +++ b/frontend/src/components/ai-builder/BranchSelector.tsx @@ -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 (
-
+

AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.

+
{/* Branch list */} diff --git a/frontend/src/components/ai-builder/GeneratingAnimation.tsx b/frontend/src/components/ai-builder/GeneratingAnimation.tsx index f372487e..a9794bac 100644 --- a/frontend/src/components/ai-builder/GeneratingAnimation.tsx +++ b/frontend/src/components/ai-builder/GeneratingAnimation.tsx @@ -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 ( -
-
-
- -
-

- {MESSAGES[messageIndex]} -

+
+ {/* Spinner */} +
+ + {/* Branch context (Generate All mode) */} + {branchContext ? ( + <> +

+ Branch {branchContext.current} of {branchContext.total} +

+

Generating branch detail...

+ + ) : ( +

Generating...

+ )}
) } diff --git a/frontend/src/components/ai-builder/TreePreviewCard.tsx b/frontend/src/components/ai-builder/TreePreviewCard.tsx index 91a383b7..730ca20d 100644 --- a/frontend/src/components/ai-builder/TreePreviewCard.tsx +++ b/frontend/src/components/ai-builder/TreePreviewCard.tsx @@ -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 */} -
+
- +
+ + +
) diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx new file mode 100644 index 00000000..ee22d06c --- /dev/null +++ b/frontend/src/components/common/PageHeader.tsx @@ -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 ( +
+
+ {icon &&
{icon}
} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {action &&
{action}
} +
+ ) +} + +export default PageHeader diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 9e6671bf..55519f45 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 ( diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx index 0a2b5dae..cc8bf2cf 100644 --- a/frontend/src/components/layout/ProtectedRoute.tsx +++ b/frontend/src/components/layout/ProtectedRoute.tsx @@ -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 (
-
+
) } diff --git a/frontend/src/components/maintenance/BatchLaunchModal.tsx b/frontend/src/components/maintenance/BatchLaunchModal.tsx index 46c091c9..43a16845 100644 --- a/frontend/src/components/maintenance/BatchLaunchModal.tsx +++ b/frontend/src/components/maintenance/BatchLaunchModal.tsx @@ -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
{savedLists === null ? (
-
+
) : savedLists.length === 0 ? (

diff --git a/frontend/src/components/session/ShareSessionModal.tsx b/frontend/src/components/session/ShareSessionModal.tsx index 19b1e94d..3d6d0d0a 100644 --- a/frontend/src/components/session/ShareSessionModal.tsx +++ b/frontend/src/components/session/ShareSessionModal.tsx @@ -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 && (

-
+
)}
diff --git a/frontend/src/components/step-library/CustomStepModal.tsx b/frontend/src/components/step-library/CustomStepModal.tsx index 6a6b6218..a232ed3f 100644 --- a/frontend/src/components/step-library/CustomStepModal.tsx +++ b/frontend/src/components/step-library/CustomStepModal.tsx @@ -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 && (
-
+

Creating step...

diff --git a/frontend/src/components/tree-editor/TreeEditorLayout.tsx b/frontend/src/components/tree-editor/TreeEditorLayout.tsx index 939a4c6f..94221c9d 100644 --- a/frontend/src/components/tree-editor/TreeEditorLayout.tsx +++ b/frontend/src/components/tree-editor/TreeEditorLayout.tsx @@ -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({ )}> -
+
}> diff --git a/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx b/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx index 6d8c127e..8f046c07 100644 --- a/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx +++ b/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx @@ -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(null) @@ -167,7 +168,7 @@ export function CodeModeEditor() { onMount={handleEditorDidMount} loading={
-
+
} options={{ diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 6266f44c..d2bbbce7 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -138,12 +138,10 @@ export function AccountSettingsPage() { if (error) { return ( -
-
-
- - {error} -
+
+
+ + {error}
) @@ -152,7 +150,7 @@ export function AccountSettingsPage() { const sub = subscription?.subscription return ( -
+
diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx index 1b1d200a..f4413ccb 100644 --- a/frontend/src/pages/ForgotPasswordPage.tsx +++ b/frontend/src/pages/ForgotPasswordPage.tsx @@ -35,7 +35,7 @@ export function ForgotPasswordPage() {
-

+

Reset Password

diff --git a/frontend/src/pages/MaintenanceFlowDetailPage.tsx b/frontend/src/pages/MaintenanceFlowDetailPage.tsx index 9875bd45..1b5c7303 100644 --- a/frontend/src/pages/MaintenanceFlowDetailPage.tsx +++ b/frontend/src/pages/MaintenanceFlowDetailPage.tsx @@ -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 (

-
+
) } - if (!tree) return null + if (!tree) { + return ( +
+ 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 + + )} + /> +
+ ) + } // Group sessions by batch_id for run history const batchMap = new Map() @@ -81,43 +101,42 @@ export default function MaintenanceFlowDetailPage() { const batches = Array.from(batchMap.entries()).slice(0, 10) return ( -
+
{/* Header */} -
-
+
-
-

{tree.name}

- {tree.description && ( -

{tree.description}

- )} + )} + titleClassName="text-xl font-semibold" + action={( +
+ + +
-
-
- - - -
-
+ )} + /> {/* Schedule Panel */}
diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index e5aa3564..47cfa700 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -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 ( -
-

Failed to load analytics data.

+
+
) } diff --git a/frontend/src/pages/QuickStartPage.tsx b/frontend/src/pages/QuickStartPage.tsx index 179f9e0a..69c48b26 100644 --- a/frontend/src/pages/QuickStartPage.tsx +++ b/frontend/src/pages/QuickStartPage.tsx @@ -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('mine') + + // Fork modal state + const [forkTarget, setForkTarget] = useState(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 (
{/* Page Header */} @@ -234,14 +280,6 @@ export function QuickStartPage() { Welcome back. Here's what's happening with your flows.

-
- {canCreateTrees && ( - setShowAIBuilder(true)} - /> - )} -
{/* Quick Stats */} @@ -354,11 +392,31 @@ export function QuickStartPage() { )}
- {/* My Flows Section */} + {/* My Flows Section — tabbed */}
-
-

My Flows

-
+
+ {tabs.map((tab) => ( + + ))} +
+ {activeTab === 'mine' && canCreateTrees && ( + setShowAIBuilder(true)} + /> + )}
@@ -371,8 +429,16 @@ export function QuickStartPage() {
) : myFlows.length === 0 ? (
-

You haven't created any flows yet.

- {canCreateTrees && ( +

+ {activeTab === 'mine' + ? "You haven't created any flows yet." + : activeTab === 'team' + ? 'No team flows found.' + : activeTab === 'public' + ? 'No public flows found.' + : 'No flows found.'} +

+ {activeTab === 'mine' && canCreateTrees && ( setShowAIBuilder(true)} @@ -497,6 +563,48 @@ export function QuickStartPage() { )}
+ {/* Fork Modal */} + {forkTarget && ( +
+
+

Fork this flow?

+

+ Creates a copy of “{forkTarget.name}” under your account that you can edit freely. +

+ + 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()} + /> +
+ + +
+
+
+ )} + {/* AI Builder Modal */} {showAIBuilder && ( setLoading(false)) }, [period, isAccountOwner, isSuperAdmin]) + useEffect(() => { + if (!isAccountOwner && !isSuperAdmin) { + toast.info('Viewing your personal analytics', { id: 'analytics-redirect' }) + } + }, [isAccountOwner, isSuperAdmin]) + if (!isAccountOwner && !isSuperAdmin) { return } @@ -60,8 +68,11 @@ export default function TeamAnalyticsPage() { if (!data) { return ( -
-

Failed to load analytics data.

+
+
) } diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 85e886d2..695d9237 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -651,7 +651,7 @@ export function TreeEditorPage() { {/* Publish */} -
+ 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" + > + + New List + + )} + /> {isLoading ? (
-
+
) : lists.length === 0 ? ( -
- -

No target lists yet

-

- Create lists of servers to reuse across maintenance runs -

-
+ } + title="No target lists yet" + description="Create lists of servers to reuse across maintenance runs." + /> ) : (
{lists.map(list => ( @@ -172,68 +173,68 @@ export default function TargetListsPage() { )} {/* Editor Modal */} - {showEditor && ( -
-
-

- {editingList ? 'Edit Target List' : 'New Target List'} -

-
-
- - 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" - /> -
-
- - 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" - /> -
-
- -