Files
resolutionflow/docs/plans/2026-02-24-visibility-dashboard-tabs-fork-ui.md
chihlasm ed4ab059bf 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>
2026-02-24 07:40:44 -05:00

24 KiB
Raw Blame History

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:

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

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

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:

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:

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:

query = select(Tree).options(
    selectinload(Tree.category_rel),
    selectinload(Tree.tags),
    selectinload(Tree.author),  # ADD THIS
)

Update build_tree_response signature and body:

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:

visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, link, public"),

In the filter section (after the is_public filter block):

if visibility:
    query = query.where(Tree.visibility == visibility)

Step 5: Write tests

In backend/tests/test_trees.py, add a new test class TestVisibilityFilter:

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

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

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:

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:

visibility?: 'private' | 'team' | 'link' | 'public'

Step 3: Verify build passes

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

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:

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:

{/* 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):

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:

{/* 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):

{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:

{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

cd /path/to/worktree/frontend && npm run build 2>&1 | tail -10

Expected: no TypeScript errors.

Step 4: Commit

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:

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:

# 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

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

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

git push

Step 2: Check PR CI

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.