+ )
+})()}
+```
+
+**Step 4: Disable individual controls during `isGeneratingAll`**
+
+On the "Generate Detail" button (the primary one in the empty-state section):
+```tsx
+disabled={isLoading || isGeneratingAll}
+```
+
+On the "Skip" button:
+```tsx
+disabled={isGeneratingAll}
+// also add: className includes opacity-50 when disabled
+```
+
+On the "Regenerate" button:
+```tsx
+disabled={isLoading || isGeneratingAll}
+```
+
+**Step 5: Pass `branchContext` to `GeneratingAnimation`**
+
+The `GeneratingAnimation` is rendered when `phase === 'generating' && isLoading`. Update that render:
+
+```tsx
+if (phase === 'generating' && isLoading) {
+ return (
+ b.steps).length + 1,
+ total: selectedBranches.length,
+ }
+ : undefined
+ }
+ />
+ )
+}
+```
+
+**Step 6: Verify build passes**
+
+```bash
+cd frontend && npm run build 2>&1 | tail -20
+```
+Expected: no errors.
+
+**Step 7: Commit**
+
+```bash
+git add frontend/src/components/ai-builder/BranchDetailView.tsx
+git commit -m "feat: add Generate All button and per-branch progress to AI builder detail stage"
+```
+
+---
+
+## Task 5: Push and verify
+
+**Step 1: Push branch**
+
+```bash
+git push
+```
+
+**Step 2: Verify CI passes**
+
+```bash
+gh pr checks 88 2>&1 | head -20
+```
+
+Expected: all checks passing (or wait for them to run).
+
+**Step 3: Manual smoke test checklist**
+
+- [ ] Open a fresh AI-generated draft in the tree editor → Publish button is enabled
+- [ ] Open AI Flow Builder, complete foundation → scaffold → select branches
+- [ ] On detail stage: "Generate All" button is visible
+- [ ] Click "Generate All" → branches generate one at a time, tabs show progress, "Branch X of Y" appears in animation
+- [ ] "Stop" button appears during run, clicking it halts after current branch
+- [ ] Activity messages cycle: "Setting up your flow..." → "Building diagnostic paths..." → "Putting the pieces in place..." → "Almost there..."
+- [ ] Single-branch generate still works as before
diff --git a/docs/plans/2026-02-24-ai-builder-ux-improvements.md b/docs/plans/2026-02-24-ai-builder-ux-improvements.md
new file mode 100644
index 00000000..21094e64
--- /dev/null
+++ b/docs/plans/2026-02-24-ai-builder-ux-improvements.md
@@ -0,0 +1,119 @@
+# AI Builder UX Improvements
+
+> **Date:** 2026-02-24
+> **Branch:** frontend-standardization (PR #88)
+> **Status:** Approved for implementation
+
+---
+
+## Overview
+
+Three focused UX improvements to the AI Flow Builder and tree editor:
+
+1. **Publish always available** — remove unnecessary `!isDirty` gate on the Publish button
+2. **Generate All branches** — one-click auto-generation of all branch details sequentially
+3. **Honest activity messages** — replace spinner-only loading with rotating status messages
+
+---
+
+## Feature 1: Publish Button Always Available
+
+### Problem
+The Publish button in `TreeEditorPage.tsx` is disabled when `!isDirty`. This prevents publishing a freshly AI-generated draft that landed in the editor already saved — the tree is valid and ready, but the button is greyed out because nothing has been locally changed.
+
+This same issue affects any future import/ingestion pathway where a draft arrives pre-saved.
+
+### Solution
+Remove `!isDirty` from the Publish button's `disabled` condition. Keep it disabled only for `isSaving || hasBlockingErrors`.
+
+The Save Draft button retains its `!isDirty` guard (no reason to re-save an unchanged draft).
+
+### Change
+**File:** `frontend/src/pages/TreeEditorPage.tsx`
+**Line:** ~654
+Before: `disabled={isSaving || !isDirty || hasBlockingErrors}`
+After: `disabled={isSaving || hasBlockingErrors}`
+
+---
+
+## Feature 2: Generate All Branches
+
+### Problem
+With 4–7 branches, users must click "Generate Detail" for each branch individually, wait for it to complete, then click the next. For 6 branches this is 6 separate interactions with wait time between each.
+
+### Solution
+Add a "Generate All" button that auto-generates all undetailed branches sequentially without user intervention.
+
+### Store changes (`aiFlowBuilderStore.ts`)
+Add:
+- `isGeneratingAll: boolean` — true while auto-run is in progress
+- `stopGeneratingAll: boolean` — flag set by user to cancel mid-run
+- `generateAllBranchDetails(): Promise` — sequential loop
+- `cancelGenerateAll(): void` — sets stop flag
+
+`generateAllBranchDetails()` logic:
+1. Set `isGeneratingAll: true`, `stopGeneratingAll: false`
+2. Loop through `selectedBranches` in order, skipping branches that already have `steps`
+3. For each: set `currentBranchIndex` to the active branch (so tabs show live progress), call `generateBranchDetail(branch.name)`
+4. After each call, check `stopGeneratingAll` — if true, break
+5. On `generateBranchDetail` failure: stop loop, set `isGeneratingAll: false`, leave error state on that branch (existing error handling handles display)
+6. On complete: set `isGeneratingAll: false`
+
+### UI changes (`BranchDetailView.tsx`)
+- Add "Generate All" button above branch tabs, visible when ≥1 branch has no `steps` and `!isGeneratingAll`
+- During a run: button becomes "Stop" (calls `cancelGenerateAll`)
+- Individual "Generate Detail" and "Skip" buttons disabled during `isGeneratingAll`
+- Branch tabs show `currentBranchIndex` highlight during run so user can see which is active
+- When `isGeneratingAll`, show "Branch X of Y" context in the generating animation
+
+### Error handling
+On failure mid-run: stop at the failed branch, show the existing error indicator on that branch tab (red instead of green check), display the error message. User can retry that branch individually or skip it. No global abort.
+
+---
+
+## Feature 3: Honest Activity Messages
+
+### Problem
+`GeneratingAnimation` shows a spinner with static text. For waits of 5–30+ seconds this gives no sense of progress.
+
+### Solution
+Replace the static text in `GeneratingAnimation` with rotating messages tied to elapsed time. Messages are always-true descriptions of what the system is doing at a high level.
+
+### Message sequence
+| Elapsed | Message |
+|---------|---------|
+| 0–4s | "Setting up your flow..." |
+| 4–12s | "Building diagnostic paths..." |
+| 12–20s | "Putting the pieces in place..." |
+| 20s+ | "Almost there..." |
+
+Messages advance on a timer that resets each time generation starts. They use `useEffect` with `setInterval` — no new dependencies needed.
+
+### Generate All context
+When `isGeneratingAll` is true, show "Branch X of Y" as a small label above the rotating message. This comes from the store's `currentBranchIndex` and `selectedBranches.length`.
+
+### Change
+**File:** `frontend/src/components/ai-builder/GeneratingAnimation.tsx`
+Replace static text with a `useEffect`-driven message cycler. Accept optional `branchContext?: { current: number; total: number }` prop.
+
+`BranchDetailView.tsx` passes `branchContext` when `isGeneratingAll` is true.
+
+---
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `frontend/src/pages/TreeEditorPage.tsx` | Remove `!isDirty` from Publish disabled condition |
+| `frontend/src/store/aiFlowBuilderStore.ts` | Add `isGeneratingAll`, `stopGeneratingAll`, `generateAllBranchDetails`, `cancelGenerateAll` |
+| `frontend/src/components/ai-builder/BranchDetailView.tsx` | Add Generate All / Stop button, disable controls during run, pass branch context |
+| `frontend/src/components/ai-builder/GeneratingAnimation.tsx` | Add rotating activity messages with elapsed timer, accept branchContext prop |
+
+No backend changes required.
+
+---
+
+## Non-Goals
+- SSE/streaming status from backend (deferred, would be needed for accurate retry messaging)
+- Parallel branch generation (rate limit risk, sequential is safer and shows progress)
+- Persisting generate-all state across modal close (not needed, user can re-run)
diff --git a/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
new file mode 100644
index 00000000..92b77a71
--- /dev/null
+++ b/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
@@ -0,0 +1,686 @@
+# Visibility Model, Dashboard Tabs & Fork UI — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Fix the dashboard stale-data bug, wire up the existing `visibility` column into access control, replace the single-list "My Flows" dashboard with a tabbed All / My Team / Public / My Flows view, and add a Fork button to public flow cards.
+
+**Architecture:** Backend: rewrite `build_tree_access_filter` to use `visibility` column, add `visibility` query param to `GET /trees`, add `author_name` to `TreeListResponse`. Frontend: convert `MyTreesPage` into a tabbed page with per-tab API calls; add Fork button on public/team cards with a minimal confirmation modal. No new migrations needed — all columns exist. `is_public` stays in sync with `visibility='public'` for backward compat.
+
+**Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Pydantic v2, React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React.
+
+**Key files:**
+- `backend/app/core/filters.py` — access filter (currently ignores `visibility`)
+- `backend/app/api/endpoints/trees.py` — list endpoint (add `visibility` param, `author_name` in response)
+- `backend/app/schemas/tree.py` — add `author_name`, `visibility` to `TreeListResponse`
+- `backend/tests/test_trees.py` — add visibility filter tests
+- `frontend/src/types/tree.ts` — add `visibility`, `author_name` to `TreeListItem`
+- `frontend/src/api/trees.ts` — add `visibility` param to `list()`
+- `frontend/src/pages/MyTreesPage.tsx` — full rewrite with tabs + stale data fix + fork button
+
+---
+
+## Task 1: Fix `build_tree_access_filter` to enforce `visibility`
+
+**Files:**
+- Modify: `backend/app/core/filters.py`
+
+### Background
+
+Currently the filter uses `is_public` boolean and `author_id`/`account_id` equality. The `visibility` column (`private | team | link | public`) is completely ignored. This means:
+- `visibility='private'` trees are still visible to team members (wrong)
+- The column is dead code
+
+We keep `is_public` in sync (`is_public = visibility == 'public'`) since other code may read it. The new filter logic is:
+
+| Condition | Sees tree |
+|---|---|
+| `is_default == True` | Everyone |
+| `visibility == 'public'` | Everyone |
+| `author_id == me` | Always (regardless of visibility) |
+| `visibility == 'team' AND account_id == mine` | Team members (not private ones) |
+
+`visibility='private'` trees are only ever visible to their author.
+`visibility='link'` trees are accessible via share token (already handled by share endpoints); they don't appear in list queries unless you are the author.
+
+**Step 1: Rewrite `build_tree_access_filter`**
+
+Replace the function body in `backend/app/core/filters.py`:
+
+```python
+def build_tree_access_filter(current_user: User):
+ """Build the access filter for trees based on user permissions.
+
+ Visibility rules:
+ - super_admin: sees everything
+ - is_default: visible to all authenticated users
+ - visibility='public': visible to all authenticated users
+ - author_id == me: always visible (regardless of visibility setting)
+ - visibility='team' AND account_id == mine: visible to account members
+ - visibility='private': only visible to author (covered by author_id check above)
+ - visibility='link': only visible to author (share token access is handled separately)
+ """
+ from app.models.tree import Tree
+
+ if current_user.is_super_admin:
+ return sa_true()
+
+ conditions = [
+ Tree.is_default == True,
+ Tree.visibility == 'public',
+ Tree.author_id == current_user.id,
+ ]
+ if current_user.account_id:
+ conditions.append(
+ and_(
+ Tree.visibility == 'team',
+ Tree.account_id == current_user.account_id
+ )
+ )
+ return or_(*conditions)
+```
+
+**Step 2: Verify no existing tests break**
+
+```bash
+cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
+```
+
+Expected: all existing tests pass (the change narrows visibility but test trees are created with default `visibility='team'` and are owned by test user so they pass via `author_id` match).
+
+**Step 3: Commit**
+
+```bash
+git add backend/app/core/filters.py
+git commit -m "fix: enforce visibility column in tree access filter
+
+Previously build_tree_access_filter used is_public boolean and ignored the
+visibility column entirely. Now private/link trees are only visible to their
+author, team trees require matching account_id, and public trees are open to all.
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 2: Add `visibility` query param and `author_name` to list endpoint
+
+**Files:**
+- Modify: `backend/app/api/endpoints/trees.py`
+- Modify: `backend/app/schemas/tree.py`
+- Modify: `backend/app/models/user.py` (confirm `full_name` or `email` field name)
+
+### Background
+
+The frontend tabs need to filter by visibility scope:
+- "My Flows" tab: `author_id=` (existing)
+- "My Team" tab: `visibility=team` (new)
+- "Public" tab: `visibility=public` (new)
+- "All" tab: no visibility filter (existing default behavior)
+
+We also want to show "Created by X" on cards that don't belong to the current user. The `TreeListResponse` needs an `author_name` field (email or display name).
+
+**Step 1: Add `author_name` to `TreeListResponse` in `backend/app/schemas/tree.py`**
+
+Add to the `TreeListResponse` class after `author_id`:
+```python
+author_name: Optional[str] = None # Display name or email of author
+```
+
+**Step 2: Add `visibility` to `TreeListResponse` in `backend/app/schemas/tree.py`**
+
+Add to `TreeListResponse` after `is_default`:
+```python
+visibility: str = 'team'
+```
+
+**Step 3: Update `build_tree_response` in `backend/app/api/endpoints/trees.py`**
+
+The `build_tree_response` function needs to include `author_name` and `visibility`. However, since it receives a `Tree` object (not a joined query), we need to either:
+a) Eagerly load the `author` relationship, or
+b) Set `author_name` from a pre-built map
+
+The cleanest approach is to update `list_trees` to eagerly load the `author` relationship and pass it through.
+
+In `list_trees`, update the query to load author:
+```python
+query = select(Tree).options(
+ selectinload(Tree.category_rel),
+ selectinload(Tree.tags),
+ selectinload(Tree.author), # ADD THIS
+)
+```
+
+Update `build_tree_response` signature and body:
+```python
+def build_tree_response(tree: Tree) -> TreeListResponse:
+ """Build TreeListResponse with category_info, tags, author_name, and visibility."""
+ category_info = None
+ if tree.category_rel:
+ category_info = CategoryInfo(
+ id=tree.category_rel.id,
+ name=tree.category_rel.name,
+ slug=tree.category_rel.slug
+ )
+
+ # Author display: prefer full_name, fall back to email
+ author_name = None
+ if tree.author:
+ author_name = getattr(tree.author, 'full_name', None) or tree.author.email
+
+ return TreeListResponse(
+ id=tree.id,
+ name=tree.name,
+ description=tree.description,
+ tree_type=tree.tree_type,
+ category=tree.category,
+ category_id=tree.category_id,
+ category_info=category_info,
+ tags=tree.tag_names,
+ author_id=tree.author_id,
+ author_name=author_name,
+ account_id=tree.account_id,
+ is_active=tree.is_active,
+ is_public=tree.is_public,
+ is_default=tree.is_default,
+ visibility=tree.visibility,
+ status=tree.status,
+ version=tree.version,
+ usage_count=tree.usage_count,
+ created_at=tree.created_at,
+ updated_at=tree.updated_at
+ )
+```
+
+**Step 4: Add `visibility` query param to `list_trees`**
+
+In the function signature, add:
+```python
+visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),
+```
+
+In the filter section (after the `is_public` filter block):
+```python
+if visibility:
+ query = query.where(Tree.visibility == visibility)
+```
+
+**Step 5: Write tests**
+
+In `backend/tests/test_trees.py`, add a new test class `TestVisibilityFilter`:
+
+```python
+class TestVisibilityFilter:
+ """Test that visibility filtering works correctly."""
+
+ @pytest.mark.asyncio
+ async def test_private_tree_only_visible_to_author(
+ self, client: AsyncClient, auth_headers: dict, test_user: dict
+ ):
+ """A private tree should NOT appear in another user's list."""
+ # Create a private tree as test_user
+ tree_data = {
+ "name": "Private Flow",
+ "tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []},
+ }
+ create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers)
+ assert create_resp.status_code == 201
+ tree_id = create_resp.json()["id"]
+
+ # Set visibility to private
+ vis_resp = await client.patch(
+ f"/api/v1/trees/{tree_id}/visibility",
+ json={"visibility": "private"},
+ headers=auth_headers
+ )
+ assert vis_resp.status_code == 200
+
+ # Verify it appears for the author
+ list_resp = await client.get("/api/v1/trees", headers=auth_headers)
+ assert list_resp.status_code == 200
+ ids = [t["id"] for t in list_resp.json()]
+ assert tree_id in ids
+
+ @pytest.mark.asyncio
+ async def test_visibility_query_param_filters_correctly(
+ self, client: AsyncClient, auth_headers: dict
+ ):
+ """?visibility=public should only return public trees."""
+ resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers)
+ assert resp.status_code == 200
+ trees = resp.json()
+ for tree in trees:
+ assert tree["visibility"] == "public"
+
+ @pytest.mark.asyncio
+ async def test_author_name_present_in_list_response(
+ self, client: AsyncClient, auth_headers: dict, test_tree: dict
+ ):
+ """TreeListResponse should include author_name."""
+ resp = await client.get("/api/v1/trees", headers=auth_headers)
+ assert resp.status_code == 200
+ trees = resp.json()
+ assert len(trees) >= 1
+ # author_name should be present (may be None for system trees)
+ assert "author_name" in trees[0]
+```
+
+**Step 6: Run tests**
+
+```bash
+cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py::TestVisibilityFilter -v --override-ini="addopts="
+```
+
+Expected: all 3 new tests pass.
+
+**Step 7: Commit**
+
+```bash
+git add backend/app/schemas/tree.py backend/app/api/endpoints/trees.py backend/tests/test_trees.py
+git commit -m "feat: add visibility filter param and author_name to tree list endpoint
+
+GET /trees now accepts ?visibility=private|team|link|public to scope results.
+TreeListResponse includes author_name (full_name or email) and visibility.
+Author relationship eagerly loaded to avoid N+1 queries.
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 3: Update frontend types and API client
+
+**Files:**
+- Modify: `frontend/src/types/tree.ts`
+- Modify: `frontend/src/api/trees.ts`
+
+### Background
+
+`TreeListItem` needs `visibility` and `author_name`. The `trees.list()` API method needs a `visibility` parameter.
+
+**Step 1: Add fields to `TreeListItem` in `frontend/src/types/tree.ts`**
+
+After `author_id: string | null`, add:
+```typescript
+author_name: string | null
+visibility: 'private' | 'team' | 'link' | 'public'
+```
+
+**Step 2: Add `visibility` to `TreeListParams` in `frontend/src/api/trees.ts`**
+
+Find the params type/interface for `list()` and add:
+```typescript
+visibility?: 'private' | 'team' | 'link' | 'public'
+```
+
+**Step 3: Verify build passes**
+
+```bash
+cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
+```
+
+Expected: no TypeScript errors. There may be type errors in `MyTreesPage.tsx` if it accesses `tree.visibility` — they'll be fixed in Task 4.
+
+**Step 4: Commit**
+
+```bash
+git add frontend/src/types/tree.ts frontend/src/api/trees.ts
+git commit -m "feat: add visibility and author_name to TreeListItem type and list API params
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 4: Rewrite `MyTreesPage` with tabs and fork button
+
+**Files:**
+- Modify: `frontend/src/pages/MyTreesPage.tsx`
+
+### Background
+
+**Current state:**
+- Single list of `author_id=me` trees
+- Data loads on mount only (stale after navigating back from editor)
+- Has a "Create New" dropdown in the header
+- Has a fork badge on cards that have `parent_tree_id`
+
+**New state:**
+- Four tabs: **My Flows** | **My Team** | **Public** | **All**
+- "My Team" tab hidden when `user.account_id` is null (solo user)
+- Data reloads when the active tab changes AND when the window regains focus (fixes stale data)
+- "Create New" button moves into the **My Flows** tab header area (only shown on that tab)
+- Cards on **Public** and **All** tabs (and **My Team** for other users' flows) show a **Fork** button
+- Fork button opens a minimal inline modal with optional reason field
+
+### Tab → API call mapping
+
+| Tab | `treesApi.list()` params |
+|-----|--------------------------|
+| My Flows | `{ author_id: user.id, sort_by: 'updated_at' }` |
+| My Team | `{ visibility: 'team', sort_by: 'updated_at' }` |
+| Public | `{ visibility: 'public', sort_by: 'usage_count' }` |
+| All | `{ sort_by: 'updated_at' }` |
+
+**Step 1: Read the current `MyTreesPage.tsx` in full before editing.**
+
+The file is at `frontend/src/pages/MyTreesPage.tsx`. Read it completely before making any changes.
+
+**Step 2: Rewrite `MyTreesPage.tsx`**
+
+Key structural changes:
+
+```tsx
+type Tab = 'mine' | 'team' | 'public' | 'all'
+
+export function MyTreesPage() {
+ const { user } = useAuthStore()
+ const { canEditTree, canCreateTrees } = usePermissions()
+ const navigate = useNavigate()
+ const hasTeam = Boolean(user?.account_id)
+
+ // Active tab state — default to 'mine'
+ const [activeTab, setActiveTab] = useState('mine')
+ const [trees, setTrees] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+
+ // Fork modal state
+ const [forkTarget, setForkTarget] = useState(null)
+ const [forkReason, setForkReason] = useState('')
+ const [isForking, setIsForking] = useState(false)
+
+ // ... existing modal state (delete, share, AI builder)
+
+ // Load trees whenever the active tab changes
+ useEffect(() => {
+ loadTrees()
+ }, [activeTab, user?.id])
+
+ // Reload on window focus (fixes stale data after navigating back from editor)
+ useEffect(() => {
+ const onFocus = () => loadTrees()
+ window.addEventListener('focus', onFocus)
+ return () => window.removeEventListener('focus', onFocus)
+ }, [activeTab, user?.id])
+
+ const loadTrees = async () => {
+ if (!user?.id) return
+ setIsLoading(true)
+ try {
+ const params: Parameters[0] = {
+ sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at',
+ }
+ if (activeTab === 'mine') params.author_id = user.id
+ if (activeTab === 'team') params.visibility = 'team'
+ if (activeTab === 'public') params.visibility = 'public'
+
+ const [userTrees, recentSessions] = await Promise.all([
+ treesApi.list(params),
+ activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]),
+ ])
+
+ // Build lastUsed map (only for mine tab)
+ const lastUsedMap = new Map()
+ for (const session of recentSessions) {
+ const existing = lastUsedMap.get(session.tree_id)
+ if (!existing || new Date(session.started_at) > new Date(existing)) {
+ lastUsedMap.set(session.tree_id, session.started_at)
+ }
+ }
+
+ setTrees(userTrees.map((tree) => ({
+ ...tree,
+ lastUsed: lastUsedMap.get(tree.id),
+ sessionCount: tree.usage_count ?? 0,
+ })))
+ } catch {
+ toast.error('Failed to load flows')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleFork = async () => {
+ if (!forkTarget) return
+ setIsForking(true)
+ try {
+ const forked = await treesApi.fork(forkTarget.id, {
+ fork_reason: forkReason.trim() || undefined,
+ })
+ toast.success(`"${forked.name}" added to your flows`)
+ setForkTarget(null)
+ setForkReason('')
+ // Switch to My Flows tab so they can see it
+ setActiveTab('mine')
+ } catch {
+ toast.error('Failed to fork flow')
+ } finally {
+ setIsForking(false)
+ }
+ }
+```
+
+**Tab bar UI** — render above the tree grid:
+```tsx
+{/* Tab bar */}
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ {/* Create button — only on My Flows tab */}
+ {activeTab === 'mine' && canCreateTrees && (
+
+ {/* existing CreateMenu / AI builder button */}
+
+ )}
+
+```
+
+Tabs array (built once, team tab conditionally included):
+```tsx
+const tabs = [
+ { id: 'mine' as Tab, label: 'My Flows' },
+ ...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []),
+ { id: 'public' as Tab, label: 'Public' },
+ { id: 'all' as Tab, label: 'All' },
+]
+```
+
+**Fork button on cards** — shown when `tree.author_id !== user?.id` OR when on the Public tab:
+
+```tsx
+{/* Show Fork button for flows you don't own */}
+{tree.author_id !== user?.id && (
+
+)}
+```
+
+**Fork confirmation modal** — simple inline modal (not a full-screen dialog):
+```tsx
+{forkTarget && (
+
+
+
Fork this flow?
+
+ Creates a copy of “{forkTarget.name}” under your account that you can edit freely.
+
+)}
+```
+
+**Author attribution on cards** — in the card header area, when `tree.author_id !== user?.id && tree.author_name`:
+```tsx
+{tree.author_id !== user?.id && tree.author_name && (
+
+ by {tree.author_name}
+
+)}
+```
+
+**Step 3: Verify build passes**
+
+```bash
+cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10
+```
+
+Expected: no TypeScript errors.
+
+**Step 4: Commit**
+
+```bash
+git add frontend/src/pages/MyTreesPage.tsx
+git commit -m "feat: add tabbed dashboard with My Flows/My Team/Public/All views and fork UI
+
+- Tabs filter by visibility scope; My Team hidden for solo users
+- Data reloads on tab change and window focus (fixes stale-after-editor bug)
+- Create button moves into My Flows tab header
+- Fork button on flows not owned by current user; opens reason modal
+- Author attribution shown on cards from other users
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 5: Keep `is_public` in sync when visibility changes
+
+**Files:**
+- Modify: `backend/app/api/endpoints/trees.py` — the `update_tree_visibility` endpoint
+
+### Background
+
+The existing `PATCH /trees/{id}/visibility` endpoint sets the `visibility` column. But `is_public` is a separate boolean that some code may still read. We need to keep them in sync so `is_public = (visibility == 'public')`.
+
+Find the visibility update endpoint (around line 1025–1077) and add the sync:
+
+**Step 1: Add `is_public` sync in the visibility endpoint**
+
+Locate the line that sets `tree.visibility = visibility_data.visibility` and add directly after it:
+```python
+tree.is_public = (visibility_data.visibility == 'public')
+```
+
+Also do the same in `update_tree` (the PUT endpoint) — find where `is_public` is set from the update data and add a corresponding `visibility` update:
+```python
+# Keep visibility and is_public in sync
+if tree_data.is_public is not None:
+ tree.is_public = tree_data.is_public
+ if tree_data.is_public and tree.visibility not in ('public',):
+ tree.visibility = 'public'
+ elif not tree_data.is_public and tree.visibility == 'public':
+ tree.visibility = 'team' # downgrade from public to team
+```
+
+**Step 2: Run existing tests**
+
+```bash
+cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts="
+```
+
+Expected: all tests pass.
+
+**Step 3: Commit**
+
+```bash
+git add backend/app/api/endpoints/trees.py
+git commit -m "fix: keep is_public and visibility in sync on updates
+
+When visibility changes to 'public', is_public=True. When it changes away
+from 'public', is_public=False. When is_public is set via TreeUpdate,
+visibility column is updated to match.
+
+Co-Authored-By: Claude Opus 4.6 "
+```
+
+---
+
+## Task 6: Push and verify
+
+**Step 1: Push branch**
+
+```bash
+git push
+```
+
+**Step 2: Check PR CI**
+
+```bash
+gh pr checks 88 2>&1 | head -20
+```
+
+**Step 3: Manual smoke test checklist**
+
+- [ ] Create a new AI flow → "Open in Editor" → publish → navigate back to dashboard. Flow should appear immediately (focus trigger reloads).
+- [ ] Dashboard has tabs: My Flows, My Team (if account), Public, All.
+- [ ] My Flows tab shows only flows you authored. Create button is here.
+- [ ] My Team tab shows team-visibility flows from your account (other team members' flows appear here).
+- [ ] Public tab shows `visibility='public'` flows from all users. Fork button visible on flows you don't own.
+- [ ] Fork a public flow → reason modal appears → confirm → toast "Added to your flows" → switches to My Flows tab → fork appears there.
+- [ ] Solo user (no account_id) sees no My Team tab.
+- [ ] Cards for other users' flows show "by [author name]" attribution.
+- [ ] Changing a flow's visibility to "private" via editor makes it disappear from team members' My Team tab (requires two user accounts to verify).
+
+---
+
+## Implementation Notes for Claude
+
+**Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/feat-ai-flow-builder`
+**Branch:** `frontend-standardization` (PR #88)
+**Run backend tests from:** `backend/venv/bin/python -m pytest ...` (worktrees share the main venv)
+**Frontend build:** `cd frontend && npm run build`
+
+**Before Task 4:** Read `MyTreesPage.tsx` in FULL before making any edits. The file is ~410 lines and has important modal state, the AI builder button, delete/share handlers that must be preserved exactly.
+
+**Task 4 note on `TreeWithStats`:** The existing interface extension adds `lastUsed` and `sessionCount`. Also add `parent_tree_id` and `parent_tree_name` which are already being used in the fork badge rendering. Keep those. Also add `visibility` and `author_name` since `TreeListItem` now includes them (inherited from the spread `...tree`).
+
+**User model field name:** Check `backend/app/models/user.py` for the display name field — it may be `full_name`, `name`, or just `email`. Use `getattr(tree.author, 'full_name', None) or tree.author.email` as a safe fallback.
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 07b6bd36..fbc5b251 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -11,8 +11,8 @@ export const apiClient = axios.create({
},
})
-// Global error handler - shows toast for common API errors
-// Pages can still catch errors explicitly if they need custom handling
+// Global error handler for shared cases only.
+// By convention, 4xx errors are handled at the page/component level.
function handleGlobalError(error: AxiosError) {
// Network error (no response from server)
if (!error.response) {
@@ -43,9 +43,8 @@ function handleGlobalError(error: AxiosError) {
return
}
- // Client errors (4xx) — don't toast globally.
- // Pages handle their own 4xx errors (permission checks, validation, not-found)
- // and many are caught silently. Global toasts here cause noisy duplicates.
+ // Client errors (4xx) remain page-owned to avoid duplicate/noisy toasts.
+ // Global handling only covers 401/429/5xx.
if (status >= 400 && status < 500) {
return
}
diff --git a/frontend/src/components/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx
index d282bcdc..8856ac13 100644
--- a/frontend/src/components/admin/PageHeader.tsx
+++ b/frontend/src/components/admin/PageHeader.tsx
@@ -1,25 +1,2 @@
-import type { ReactNode } from 'react'
-import { cn } from '@/lib/utils'
-
-interface PageHeaderProps {
- title: string
- description?: string
- action?: ReactNode
- className?: string
-}
-
-export function PageHeader({ title, description, action, className }: PageHeaderProps) {
- return (
-
+ )
+ }
// Group sessions by batch_id for run history
const batchMap = new Map()
@@ -81,43 +101,42 @@ export default function MaintenanceFlowDetailPage() {
const batches = Array.from(batchMap.entries()).slice(0, 10)
return (
-