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:
chihlasm
2026-02-24 07:40:44 -05:00
committed by GitHub
parent 97cd297f46
commit ed4ab059bf
41 changed files with 1909 additions and 315 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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]

View 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

View 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 47 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 530+ 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 |
|---------|---------|
| 04s | "Setting up your flow..." |
| 412s | "Building diagnostic paths..." |
| 1220s | "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)

View 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 &ldquo;{forkTarget.name}&rdquo; 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 10251077) 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.

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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>
)
}

View File

@@ -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>
)

View 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

View File

@@ -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 (

View File

@@ -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>
)
}

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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={{

View File

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

View File

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

View File

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

View File

@@ -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>
)
}

View File

@@ -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&apos;s what&apos;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&apos;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 &ldquo;{forkTarget.name}&rdquo; 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

View File

@@ -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>
)
}

View File

@@ -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',

View File

@@ -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' && (

View File

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

View File

@@ -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 &mdash; 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 &mdash; 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

View File

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

View File

@@ -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>

View File

@@ -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,
})
},

View File

@@ -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

View File

@@ -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
}