# Visibility Model, Dashboard Tabs & Fork UI — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Fix the dashboard stale-data bug, wire up the existing `visibility` column into access control, replace the single-list "My Flows" dashboard with a tabbed All / My Team / Public / My Flows view, and add a Fork button to public flow cards. **Architecture:** Backend: rewrite `build_tree_access_filter` to use `visibility` column, add `visibility` query param to `GET /trees`, add `author_name` to `TreeListResponse`. Frontend: convert `MyTreesPage` into a tabbed page with per-tab API calls; add Fork button on public/team cards with a minimal confirmation modal. No new migrations needed — all columns exist. `is_public` stays in sync with `visibility='public'` for backward compat. **Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Pydantic v2, React 19, TypeScript, Zustand, Tailwind CSS v3, Lucide React. **Key files:** - `backend/app/core/filters.py` — access filter (currently ignores `visibility`) - `backend/app/api/endpoints/trees.py` — list endpoint (add `visibility` param, `author_name` in response) - `backend/app/schemas/tree.py` — add `author_name`, `visibility` to `TreeListResponse` - `backend/tests/test_trees.py` — add visibility filter tests - `frontend/src/types/tree.ts` — add `visibility`, `author_name` to `TreeListItem` - `frontend/src/api/trees.ts` — add `visibility` param to `list()` - `frontend/src/pages/MyTreesPage.tsx` — full rewrite with tabs + stale data fix + fork button --- ## Task 1: Fix `build_tree_access_filter` to enforce `visibility` **Files:** - Modify: `backend/app/core/filters.py` ### Background Currently the filter uses `is_public` boolean and `author_id`/`account_id` equality. The `visibility` column (`private | team | link | public`) is completely ignored. This means: - `visibility='private'` trees are still visible to team members (wrong) - The column is dead code We keep `is_public` in sync (`is_public = visibility == 'public'`) since other code may read it. The new filter logic is: | Condition | Sees tree | |---|---| | `is_default == True` | Everyone | | `visibility == 'public'` | Everyone | | `author_id == me` | Always (regardless of visibility) | | `visibility == 'team' AND account_id == mine` | Team members (not private ones) | `visibility='private'` trees are only ever visible to their author. `visibility='link'` trees are accessible via share token (already handled by share endpoints); they don't appear in list queries unless you are the author. **Step 1: Rewrite `build_tree_access_filter`** Replace the function body in `backend/app/core/filters.py`: ```python def build_tree_access_filter(current_user: User): """Build the access filter for trees based on user permissions. Visibility rules: - super_admin: sees everything - is_default: visible to all authenticated users - visibility='public': visible to all authenticated users - author_id == me: always visible (regardless of visibility setting) - visibility='team' AND account_id == mine: visible to account members - visibility='private': only visible to author (covered by author_id check above) - visibility='link': only visible to author (share token access is handled separately) """ from app.models.tree import Tree if current_user.is_super_admin: return sa_true() conditions = [ Tree.is_default == True, Tree.visibility == 'public', Tree.author_id == current_user.id, ] if current_user.account_id: conditions.append( and_( Tree.visibility == 'team', Tree.account_id == current_user.account_id ) ) return or_(*conditions) ``` **Step 2: Verify no existing tests break** ```bash cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts=" ``` Expected: all existing tests pass (the change narrows visibility but test trees are created with default `visibility='team'` and are owned by test user so they pass via `author_id` match). **Step 3: Commit** ```bash git add backend/app/core/filters.py git commit -m "fix: enforce visibility column in tree access filter Previously build_tree_access_filter used is_public boolean and ignored the visibility column entirely. Now private/link trees are only visible to their author, team trees require matching account_id, and public trees are open to all. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 2: Add `visibility` query param and `author_name` to list endpoint **Files:** - Modify: `backend/app/api/endpoints/trees.py` - Modify: `backend/app/schemas/tree.py` - Modify: `backend/app/models/user.py` (confirm `full_name` or `email` field name) ### Background The frontend tabs need to filter by visibility scope: - "My Flows" tab: `author_id=` (existing) - "My Team" tab: `visibility=team` (new) - "Public" tab: `visibility=public` (new) - "All" tab: no visibility filter (existing default behavior) We also want to show "Created by X" on cards that don't belong to the current user. The `TreeListResponse` needs an `author_name` field (email or display name). **Step 1: Add `author_name` to `TreeListResponse` in `backend/app/schemas/tree.py`** Add to the `TreeListResponse` class after `author_id`: ```python author_name: Optional[str] = None # Display name or email of author ``` **Step 2: Add `visibility` to `TreeListResponse` in `backend/app/schemas/tree.py`** Add to `TreeListResponse` after `is_default`: ```python visibility: str = 'team' ``` **Step 3: Update `build_tree_response` in `backend/app/api/endpoints/trees.py`** The `build_tree_response` function needs to include `author_name` and `visibility`. However, since it receives a `Tree` object (not a joined query), we need to either: a) Eagerly load the `author` relationship, or b) Set `author_name` from a pre-built map The cleanest approach is to update `list_trees` to eagerly load the `author` relationship and pass it through. In `list_trees`, update the query to load author: ```python query = select(Tree).options( selectinload(Tree.category_rel), selectinload(Tree.tags), selectinload(Tree.author), # ADD THIS ) ``` Update `build_tree_response` signature and body: ```python def build_tree_response(tree: Tree) -> TreeListResponse: """Build TreeListResponse with category_info, tags, author_name, and visibility.""" category_info = None if tree.category_rel: category_info = CategoryInfo( id=tree.category_rel.id, name=tree.category_rel.name, slug=tree.category_rel.slug ) # Author display: prefer full_name, fall back to email author_name = None if tree.author: author_name = getattr(tree.author, 'full_name', None) or tree.author.email return TreeListResponse( id=tree.id, name=tree.name, description=tree.description, tree_type=tree.tree_type, category=tree.category, category_id=tree.category_id, category_info=category_info, tags=tree.tag_names, author_id=tree.author_id, author_name=author_name, account_id=tree.account_id, is_active=tree.is_active, is_public=tree.is_public, is_default=tree.is_default, visibility=tree.visibility, status=tree.status, version=tree.version, usage_count=tree.usage_count, created_at=tree.created_at, updated_at=tree.updated_at ) ``` **Step 4: Add `visibility` query param to `list_trees`** In the function signature, add: ```python visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"), ``` In the filter section (after the `is_public` filter block): ```python if visibility: query = query.where(Tree.visibility == visibility) ``` **Step 5: Write tests** In `backend/tests/test_trees.py`, add a new test class `TestVisibilityFilter`: ```python class TestVisibilityFilter: """Test that visibility filtering works correctly.""" @pytest.mark.asyncio async def test_private_tree_only_visible_to_author( self, client: AsyncClient, auth_headers: dict, test_user: dict ): """A private tree should NOT appear in another user's list.""" # Create a private tree as test_user tree_data = { "name": "Private Flow", "tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []}, } create_resp = await client.post("/api/v1/trees", json=tree_data, headers=auth_headers) assert create_resp.status_code == 201 tree_id = create_resp.json()["id"] # Set visibility to private vis_resp = await client.patch( f"/api/v1/trees/{tree_id}/visibility", json={"visibility": "private"}, headers=auth_headers ) assert vis_resp.status_code == 200 # Verify it appears for the author list_resp = await client.get("/api/v1/trees", headers=auth_headers) assert list_resp.status_code == 200 ids = [t["id"] for t in list_resp.json()] assert tree_id in ids @pytest.mark.asyncio async def test_visibility_query_param_filters_correctly( self, client: AsyncClient, auth_headers: dict ): """?visibility=public should only return public trees.""" resp = await client.get("/api/v1/trees?visibility=public", headers=auth_headers) assert resp.status_code == 200 trees = resp.json() for tree in trees: assert tree["visibility"] == "public" @pytest.mark.asyncio async def test_author_name_present_in_list_response( self, client: AsyncClient, auth_headers: dict, test_tree: dict ): """TreeListResponse should include author_name.""" resp = await client.get("/api/v1/trees", headers=auth_headers) assert resp.status_code == 200 trees = resp.json() assert len(trees) >= 1 # author_name should be present (may be None for system trees) assert "author_name" in trees[0] ``` **Step 6: Run tests** ```bash cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py::TestVisibilityFilter -v --override-ini="addopts=" ``` Expected: all 3 new tests pass. **Step 7: Commit** ```bash git add backend/app/schemas/tree.py backend/app/api/endpoints/trees.py backend/tests/test_trees.py git commit -m "feat: add visibility filter param and author_name to tree list endpoint GET /trees now accepts ?visibility=private|team|link|public to scope results. TreeListResponse includes author_name (full_name or email) and visibility. Author relationship eagerly loaded to avoid N+1 queries. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 3: Update frontend types and API client **Files:** - Modify: `frontend/src/types/tree.ts` - Modify: `frontend/src/api/trees.ts` ### Background `TreeListItem` needs `visibility` and `author_name`. The `trees.list()` API method needs a `visibility` parameter. **Step 1: Add fields to `TreeListItem` in `frontend/src/types/tree.ts`** After `author_id: string | null`, add: ```typescript author_name: string | null visibility: 'private' | 'team' | 'link' | 'public' ``` **Step 2: Add `visibility` to `TreeListParams` in `frontend/src/api/trees.ts`** Find the params type/interface for `list()` and add: ```typescript visibility?: 'private' | 'team' | 'link' | 'public' ``` **Step 3: Verify build passes** ```bash cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10 ``` Expected: no TypeScript errors. There may be type errors in `MyTreesPage.tsx` if it accesses `tree.visibility` — they'll be fixed in Task 4. **Step 4: Commit** ```bash git add frontend/src/types/tree.ts frontend/src/api/trees.ts git commit -m "feat: add visibility and author_name to TreeListItem type and list API params Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 4: Rewrite `MyTreesPage` with tabs and fork button **Files:** - Modify: `frontend/src/pages/MyTreesPage.tsx` ### Background **Current state:** - Single list of `author_id=me` trees - Data loads on mount only (stale after navigating back from editor) - Has a "Create New" dropdown in the header - Has a fork badge on cards that have `parent_tree_id` **New state:** - Four tabs: **My Flows** | **My Team** | **Public** | **All** - "My Team" tab hidden when `user.account_id` is null (solo user) - Data reloads when the active tab changes AND when the window regains focus (fixes stale data) - "Create New" button moves into the **My Flows** tab header area (only shown on that tab) - Cards on **Public** and **All** tabs (and **My Team** for other users' flows) show a **Fork** button - Fork button opens a minimal inline modal with optional reason field ### Tab → API call mapping | Tab | `treesApi.list()` params | |-----|--------------------------| | My Flows | `{ author_id: user.id, sort_by: 'updated_at' }` | | My Team | `{ visibility: 'team', sort_by: 'updated_at' }` | | Public | `{ visibility: 'public', sort_by: 'usage_count' }` | | All | `{ sort_by: 'updated_at' }` | **Step 1: Read the current `MyTreesPage.tsx` in full before editing.** The file is at `frontend/src/pages/MyTreesPage.tsx`. Read it completely before making any changes. **Step 2: Rewrite `MyTreesPage.tsx`** Key structural changes: ```tsx type Tab = 'mine' | 'team' | 'public' | 'all' export function MyTreesPage() { const { user } = useAuthStore() const { canEditTree, canCreateTrees } = usePermissions() const navigate = useNavigate() const hasTeam = Boolean(user?.account_id) // Active tab state — default to 'mine' const [activeTab, setActiveTab] = useState('mine') const [trees, setTrees] = useState([]) const [isLoading, setIsLoading] = useState(true) // Fork modal state const [forkTarget, setForkTarget] = useState(null) const [forkReason, setForkReason] = useState('') const [isForking, setIsForking] = useState(false) // ... existing modal state (delete, share, AI builder) // Load trees whenever the active tab changes useEffect(() => { loadTrees() }, [activeTab, user?.id]) // Reload on window focus (fixes stale data after navigating back from editor) useEffect(() => { const onFocus = () => loadTrees() window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) }, [activeTab, user?.id]) const loadTrees = async () => { if (!user?.id) return setIsLoading(true) try { const params: Parameters[0] = { sort_by: activeTab === 'public' ? 'usage_count' : 'updated_at', } if (activeTab === 'mine') params.author_id = user.id if (activeTab === 'team') params.visibility = 'team' if (activeTab === 'public') params.visibility = 'public' const [userTrees, recentSessions] = await Promise.all([ treesApi.list(params), activeTab === 'mine' ? sessionsApi.list({ size: 100 }) : Promise.resolve([]), ]) // Build lastUsed map (only for mine tab) const lastUsedMap = new Map() for (const session of recentSessions) { const existing = lastUsedMap.get(session.tree_id) if (!existing || new Date(session.started_at) > new Date(existing)) { lastUsedMap.set(session.tree_id, session.started_at) } } setTrees(userTrees.map((tree) => ({ ...tree, lastUsed: lastUsedMap.get(tree.id), sessionCount: tree.usage_count ?? 0, }))) } catch { toast.error('Failed to load flows') } finally { setIsLoading(false) } } const handleFork = async () => { if (!forkTarget) return setIsForking(true) try { const forked = await treesApi.fork(forkTarget.id, { fork_reason: forkReason.trim() || undefined, }) toast.success(`"${forked.name}" added to your flows`) setForkTarget(null) setForkReason('') // Switch to My Flows tab so they can see it setActiveTab('mine') } catch { toast.error('Failed to fork flow') } finally { setIsForking(false) } } ``` **Tab bar UI** — render above the tree grid: ```tsx {/* Tab bar */}
{tabs.map((tab) => ( ))} {/* Create button — only on My Flows tab */} {activeTab === 'mine' && canCreateTrees && (
{/* existing CreateMenu / AI builder button */}
)}
``` Tabs array (built once, team tab conditionally included): ```tsx const tabs = [ { id: 'mine' as Tab, label: 'My Flows' }, ...(hasTeam ? [{ id: 'team' as Tab, label: 'My Team' }] : []), { id: 'public' as Tab, label: 'Public' }, { id: 'all' as Tab, label: 'All' }, ] ``` **Fork button on cards** — shown when `tree.author_id !== user?.id` OR when on the Public tab: ```tsx {/* Show Fork button for flows you don't own */} {tree.author_id !== user?.id && ( )} ``` **Fork confirmation modal** — simple inline modal (not a full-screen dialog): ```tsx {forkTarget && (

Fork this flow?

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

setForkReason(e.target.value)} placeholder="e.g. Adding Cisco Meraki steps for our network" maxLength={255} className="mb-4 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" onKeyDown={(e) => e.key === 'Enter' && handleFork()} />
)} ``` **Author attribution on cards** — in the card header area, when `tree.author_id !== user?.id && tree.author_name`: ```tsx {tree.author_id !== user?.id && tree.author_name && (

by {tree.author_name}

)} ``` **Step 3: Verify build passes** ```bash cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10 ``` Expected: no TypeScript errors. **Step 4: Commit** ```bash git add frontend/src/pages/MyTreesPage.tsx git commit -m "feat: add tabbed dashboard with My Flows/My Team/Public/All views and fork UI - Tabs filter by visibility scope; My Team hidden for solo users - Data reloads on tab change and window focus (fixes stale-after-editor bug) - Create button moves into My Flows tab header - Fork button on flows not owned by current user; opens reason modal - Author attribution shown on cards from other users Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 5: Keep `is_public` in sync when visibility changes **Files:** - Modify: `backend/app/api/endpoints/trees.py` — the `update_tree_visibility` endpoint ### Background The existing `PATCH /trees/{id}/visibility` endpoint sets the `visibility` column. But `is_public` is a separate boolean that some code may still read. We need to keep them in sync so `is_public = (visibility == 'public')`. Find the visibility update endpoint (around line 1025–1077) and add the sync: **Step 1: Add `is_public` sync in the visibility endpoint** Locate the line that sets `tree.visibility = visibility_data.visibility` and add directly after it: ```python tree.is_public = (visibility_data.visibility == 'public') ``` Also do the same in `update_tree` (the PUT endpoint) — find where `is_public` is set from the update data and add a corresponding `visibility` update: ```python # Keep visibility and is_public in sync if tree_data.is_public is not None: tree.is_public = tree_data.is_public if tree_data.is_public and tree.visibility not in ('public',): tree.visibility = 'public' elif not tree_data.is_public and tree.visibility == 'public': tree.visibility = 'team' # downgrade from public to team ``` **Step 2: Run existing tests** ```bash cd /path/to/worktree && backend/venv/bin/python -m pytest backend/tests/test_trees.py -x -q --override-ini="addopts=" ``` Expected: all tests pass. **Step 3: Commit** ```bash git add backend/app/api/endpoints/trees.py git commit -m "fix: keep is_public and visibility in sync on updates When visibility changes to 'public', is_public=True. When it changes away from 'public', is_public=False. When is_public is set via TreeUpdate, visibility column is updated to match. Co-Authored-By: Claude Opus 4.6 " ``` --- ## Task 6: Push and verify **Step 1: Push branch** ```bash git push ``` **Step 2: Check PR CI** ```bash gh pr checks 88 2>&1 | head -20 ``` **Step 3: Manual smoke test checklist** - [ ] Create a new AI flow → "Open in Editor" → publish → navigate back to dashboard. Flow should appear immediately (focus trigger reloads). - [ ] Dashboard has tabs: My Flows, My Team (if account), Public, All. - [ ] My Flows tab shows only flows you authored. Create button is here. - [ ] My Team tab shows team-visibility flows from your account (other team members' flows appear here). - [ ] Public tab shows `visibility='public'` flows from all users. Fork button visible on flows you don't own. - [ ] Fork a public flow → reason modal appears → confirm → toast "Added to your flows" → switches to My Flows tab → fork appears there. - [ ] Solo user (no account_id) sees no My Team tab. - [ ] Cards for other users' flows show "by [author name]" attribution. - [ ] Changing a flow's visibility to "private" via editor makes it disappear from team members' My Team tab (requires two user accounts to verify). --- ## Implementation Notes for Claude **Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/feat-ai-flow-builder` **Branch:** `frontend-standardization` (PR #88) **Run backend tests from:** `backend/venv/bin/python -m pytest ...` (worktrees share the main venv) **Frontend build:** `cd frontend && npm run build` **Before Task 4:** Read `MyTreesPage.tsx` in FULL before making any edits. The file is ~410 lines and has important modal state, the AI builder button, delete/share handlers that must be preserved exactly. **Task 4 note on `TreeWithStats`:** The existing interface extension adds `lastUsed` and `sessionCount`. Also add `parent_tree_id` and `parent_tree_name` which are already being used in the fork badge rendering. Keep those. Also add `visibility` and `author_name` since `TreeListItem` now includes them (inherited from the spread `...tree`). **User model field name:** Check `backend/app/models/user.py` for the display name field — it may be `full_name`, `name`, or just `email`. Use `getattr(tree.author, 'full_name', None) or tree.author.email` as a safe fallback.