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

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