diff --git a/docs/plans/2026-02-20-dashboard-implementation-plan.md b/docs/plans/2026-02-20-dashboard-implementation-plan.md new file mode 100644 index 00000000..0999d3c9 --- /dev/null +++ b/docs/plans/2026-02-20-dashboard-implementation-plan.md @@ -0,0 +1,745 @@ +# Dashboard: My Flows, Favorites/Pin UI, and AI Builder — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform the dashboard into a structured personal workspace with a Favorites section (pinned flows), paginated "My Flows", shared pin store, and AI Builder access from the Library page. + +**Architecture:** Zustand store (`pinnedFlowsStore`) becomes single source of truth for pin state across Sidebar, Dashboard, and Library. URL-synced pagination hook manages My Flows paging. Cached quota hook avoids redundant AI quota fetches. All three library view components gain optional pin button props. + +**Tech Stack:** React 19, Zustand, React Router v7, Tailwind CSS, Lucide icons, Axios + +**Design doc:** `docs/plans/2026-02-20-final-dashboard-plan.md` + +--- + +## Task 1: Add `pin()` to `pinnedFlowsApi` + +**Files:** +- Modify: `frontend/src/api/pinnedFlows.ts` + +**Step 1: Add `pin` method** + +In `frontend/src/api/pinnedFlows.ts`, add after the `unpin` method: + +```typescript +pin: async (treeId: string): Promise => { + await apiClient.post(`/trees/${treeId}/pin`) +}, +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` +Expected: No errors + +**Step 3: Commit** + +```bash +git add frontend/src/api/pinnedFlows.ts +git commit -m "feat: add pin() method to pinnedFlowsApi" +``` + +--- + +## Task 2: Create `pinnedFlowsStore` + +**Files:** +- Create: `frontend/src/store/pinnedFlowsStore.ts` + +**Step 1: Create the store** + +Create `frontend/src/store/pinnedFlowsStore.ts`: + +```typescript +import { create } from 'zustand' +import { pinnedFlowsApi } from '@/api/pinnedFlows' +import type { PinnedFlow } from '@/api/pinnedFlows' +import { toast } from '@/lib/toast' + +interface PinnedFlowsState { + items: PinnedFlow[] + isLoaded: boolean + isLoading: boolean + isMutatingByTreeId: Record + error: string | null + + // Actions + load: (force?: boolean) => Promise + pin: (treeId: string) => Promise + unpin: (treeId: string) => Promise + toggle: (treeId: string) => void + + // Derived helpers + isPinned: (treeId: string) => boolean +} + +export const usePinnedFlowsStore = create()((set, get) => ({ + items: [], + isLoaded: false, + isLoading: false, + isMutatingByTreeId: {}, + error: null, + + load: async (force = false) => { + const state = get() + if (state.isLoaded && !force) return + if (state.isLoading) return + + set({ isLoading: true, error: null }) + try { + const data = await pinnedFlowsApi.list() + set({ items: data.items, isLoaded: true, isLoading: false }) + } catch { + set({ error: 'Failed to load pinned flows', isLoading: false }) + } + }, + + pin: async (treeId: string) => { + const state = get() + if (state.isMutatingByTreeId[treeId]) return + + set({ isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true } }) + + try { + await pinnedFlowsApi.pin(treeId) + // Reload to get full PinnedFlow object with tree_name, tree_type, etc. + const data = await pinnedFlowsApi.list() + set((s) => ({ + items: data.items, + isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false }, + })) + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status + if (status === 409) { + toast.error('Maximum of 15 favorites reached. Unpin a flow to add a new one.') + } else { + toast.error('Failed to pin flow') + } + set((s) => ({ + isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false }, + })) + } + }, + + unpin: async (treeId: string) => { + const state = get() + if (state.isMutatingByTreeId[treeId]) return + + // Optimistic remove + const prevItems = state.items + set({ + items: state.items.filter((f) => f.tree_id !== treeId), + isMutatingByTreeId: { ...state.isMutatingByTreeId, [treeId]: true }, + }) + + try { + await pinnedFlowsApi.unpin(treeId) + set((s) => ({ + isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false }, + })) + } catch { + // Rollback + toast.error('Failed to unpin flow') + set((s) => ({ + items: prevItems, + isMutatingByTreeId: { ...s.isMutatingByTreeId, [treeId]: false }, + })) + } + }, + + toggle: (treeId: string) => { + const state = get() + if (state.isPinned(treeId)) { + state.unpin(treeId) + } else { + state.pin(treeId) + } + }, + + isPinned: (treeId: string) => { + return get().items.some((f) => f.tree_id === treeId) + }, +})) + +// Derived selectors (use outside component or in selectors) +export const selectPinnedTreeIds = (state: PinnedFlowsState): Set => + new Set(state.items.map((f) => f.tree_id)) + +export const selectPinLoadingTreeIds = (state: PinnedFlowsState): Set => + new Set( + Object.entries(state.isMutatingByTreeId) + .filter(([, v]) => v) + .map(([k]) => k) + ) +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` +Expected: No errors + +**Step 3: Commit** + +```bash +git add frontend/src/store/pinnedFlowsStore.ts +git commit -m "feat: create pinnedFlowsStore — single source of truth for pin state" +``` + +--- + +## Task 3: Add `dashboardMyFlowsView` to `userPreferencesStore` + +**Files:** +- Modify: `frontend/src/store/userPreferencesStore.ts` + +**Step 1: Add preference** + +In the `UserPreferencesState` interface, add: +```typescript +dashboardMyFlowsView: 'grid' | 'list' | 'table' +setDashboardMyFlowsView: (view: 'grid' | 'list' | 'table') => void +``` + +In the store implementation, add alongside existing fields: +```typescript +dashboardMyFlowsView: 'grid', +setDashboardMyFlowsView: (view) => set({ dashboardMyFlowsView: view }), +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/store/userPreferencesStore.ts +git commit -m "feat: add dashboardMyFlowsView preference (independent from Library)" +``` + +--- + +## Task 4: Replace local pin state in Sidebar with store + +**Files:** +- Modify: `frontend/src/components/layout/Sidebar.tsx` + +**Step 1: Swap to store** + +Remove from imports: +- `pinnedFlowsApi` import +- `PinnedFlow` type import + +Add import: +```typescript +import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore' +``` + +Remove from component: +- `const [pinnedFlows, setPinnedFlows] = useState([])` +- The `pinnedFlowsApi.list()` call from the `useEffect` +- The entire `handleUnpin` function + +Replace with: +```typescript +const pinnedItems = usePinnedFlowsStore((s) => s.items) +const loadPinned = usePinnedFlowsStore((s) => s.load) +const unpinFlow = usePinnedFlowsStore((s) => s.unpin) +``` + +In the `useEffect` that fetches data, remove the `pinnedFlowsApi.list()` from `Promise.all`. Add a separate call: +```typescript +useEffect(() => { loadPinned() }, [loadPinned]) +``` + +Update `PinnedFlowsSection` props: +```tsx + +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/components/layout/Sidebar.tsx +git commit -m "refactor: sidebar uses pinnedFlowsStore instead of local state" +``` + +--- + +## Task 5: Create `usePaginationParams` hook + +**Files:** +- Create: `frontend/src/hooks/usePaginationParams.ts` + +**Step 1: Create the hook** + +```typescript +import { useSearchParams } from 'react-router-dom' +import { useCallback, useMemo } from 'react' + +type PageSize = number | 'all' + +interface UsePaginationParamsOptions { + defaultPageSize?: number + allowedPageSizes?: PageSize[] +} + +export function usePaginationParams(options: UsePaginationParamsOptions = {}) { + const { defaultPageSize = 10, allowedPageSizes = [10, 25, 50, 'all'] } = options + const [searchParams, setSearchParams] = useSearchParams() + + const page = useMemo(() => { + const raw = searchParams.get('page') + const n = raw ? parseInt(raw, 10) : 1 + return Number.isFinite(n) && n >= 1 ? n : 1 + }, [searchParams]) + + const pageSize = useMemo((): PageSize => { + const raw = searchParams.get('size') + if (raw === 'all' && allowedPageSizes.includes('all')) return 'all' + const n = raw ? parseInt(raw, 10) : defaultPageSize + if (Number.isFinite(n) && allowedPageSizes.includes(n)) return n + return defaultPageSize + }, [searchParams, defaultPageSize, allowedPageSizes]) + + const setPage = useCallback( + (newPage: number) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + if (newPage <= 1) { + next.delete('page') + } else { + next.set('page', String(newPage)) + } + return next + }) + }, + [setSearchParams] + ) + + const setPageSize = useCallback( + (newSize: PageSize) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + next.set('size', String(newSize)) + next.delete('page') // reset to page 1 + return next + }) + }, + [setSearchParams] + ) + + return { page, pageSize, setPage, setPageSize } +} +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/hooks/usePaginationParams.ts +git commit -m "feat: create usePaginationParams hook — URL-synced pagination" +``` + +--- + +## Task 6: Create `useCachedQuota` hook + +**Files:** +- Create: `frontend/src/hooks/useCachedQuota.ts` + +**Step 1: Create the hook** + +```typescript +import { useState, useEffect } from 'react' +import { aiBuilderApi } from '@/api' + +const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes + +let cachedResult: { aiEnabled: boolean; timestamp: number } | null = null + +export function useCachedQuota() { + const [aiEnabled, setAiEnabled] = useState(cachedResult?.aiEnabled ?? false) + const [isLoading, setIsLoading] = useState(!cachedResult) + + useEffect(() => { + if (cachedResult && Date.now() - cachedResult.timestamp < CACHE_TTL_MS) { + setAiEnabled(cachedResult.aiEnabled) + setIsLoading(false) + return + } + + setIsLoading(true) + aiBuilderApi + .getQuota() + .then((q) => { + cachedResult = { aiEnabled: q.ai_enabled, timestamp: Date.now() } + setAiEnabled(q.ai_enabled) + }) + .catch(() => { + // Leave as false + }) + .finally(() => setIsLoading(false)) + }, []) + + return { aiEnabled, isLoading } +} +``` + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/hooks/useCachedQuota.ts +git commit -m "feat: create useCachedQuota hook — 5-min TTL AI quota cache" +``` + +--- + +## Task 7: Add pin button props to `TreeGridView` + +**Files:** +- Modify: `frontend/src/components/library/TreeGridView.tsx` + +**Step 1: Read current file to understand structure** + +Read `frontend/src/components/library/TreeGridView.tsx` fully before editing. + +**Step 2: Add optional pin props to interface** + +Add to `TreeGridViewProps`: +```typescript +pinnedTreeIds?: Set +onTogglePin?: (treeId: string) => void +pinLoadingTreeIds?: Set +``` + +**Step 3: Add star button to each card** + +Import `Star` from `lucide-react`. In each card's top-right area, render (only when `onTogglePin` is provided): + +```tsx +{onTogglePin && ( + +)} +``` + +**Step 4: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 5: Commit** + +```bash +git add frontend/src/components/library/TreeGridView.tsx +git commit -m "feat: add optional pin/favorite button to TreeGridView" +``` + +--- + +## Task 8: Add pin button props to `TreeListView` + +**Files:** +- Modify: `frontend/src/components/library/TreeListView.tsx` + +**Step 1: Read the file, then add same optional props and star button pattern as Task 7.** + +Place the star button at the end of each row (before the actions area). Use the exact same props interface addition and button pattern from Task 7. + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/components/library/TreeListView.tsx +git commit -m "feat: add optional pin/favorite button to TreeListView" +``` + +--- + +## Task 9: Add pin button props to `TreeTableView` + +**Files:** +- Modify: `frontend/src/components/library/TreeTableView.tsx` + +**Step 1: Read the file, then add same optional props.** + +Add a narrow "Favorite" column as the leftmost column. Header: star icon. Cell: same button pattern from Task 7. Only render the column when `onTogglePin` is provided. + +**Step 2: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 3: Commit** + +```bash +git add frontend/src/components/library/TreeTableView.tsx +git commit -m "feat: add optional pin/favorite column to TreeTableView" +``` + +--- + +## Task 10: TreeLibraryPage — Create dropdown + AI Builder + pin wiring + +**Files:** +- Modify: `frontend/src/pages/TreeLibraryPage.tsx` + +**Step 1: Add imports** + +```typescript +import { ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react' +import { usePinnedFlowsStore, selectPinnedTreeIds, selectPinLoadingTreeIds } from '@/store/pinnedFlowsStore' +import { useCachedQuota } from '@/hooks/useCachedQuota' +import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' +``` + +**Step 2: Add state and store selectors** + +Inside the component, add: +```typescript +const [showCreateMenu, setShowCreateMenu] = useState(false) +const [showAIBuilder, setShowAIBuilder] = useState(false) +const { aiEnabled } = useCachedQuota() + +const pinnedTreeIds = usePinnedFlowsStore(selectPinnedTreeIds) +const pinLoadingTreeIds = usePinnedFlowsStore(selectPinLoadingTreeIds) +const togglePin = usePinnedFlowsStore((s) => s.toggle) +const loadPinned = usePinnedFlowsStore((s) => s.load) + +useEffect(() => { loadPinned() }, [loadPinned]) +``` + +**Step 3: Replace the `` create button** + +Replace the single `` create button with the dropdown menu pattern from `MyTreesPage.tsx` (lines 134-197). Copy that exact pattern — it already has Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, and Build with AI (conditional on `aiEnabled`). + +**Step 4: Pass pin props to view components** + +Add to each `TreeGridView`, `TreeListView`, `TreeTableView` render: +```tsx +pinnedTreeIds={pinnedTreeIds} +onTogglePin={togglePin} +pinLoadingTreeIds={pinLoadingTreeIds} +``` + +**Step 5: Add AI Builder modal** + +Before the closing `` of the component, add: +```tsx +{showAIBuilder && ( + setShowAIBuilder(false)} + /> +)} +``` + +**Step 6: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 7: Commit** + +```bash +git add frontend/src/pages/TreeLibraryPage.tsx +git commit -m "feat: Library page — create dropdown with AI Builder + pin controls on all views" +``` + +--- + +## Task 11: QuickStartPage — Favorites section + paginated My Flows + +**Files:** +- Modify: `frontend/src/pages/QuickStartPage.tsx` + +This is the largest task. The page gets a major refactor. + +**Step 1: Read the current file fully (already done in exploration)** + +**Step 2: Rewrite QuickStartPage** + +The page structure becomes: + +1. **Page header** with "Create" dropdown (same pattern as Task 10) +2. **QuickStats** (keep existing) +3. **Search** (keep existing inline search) +4. **Recent Sessions** (keep existing `SessionsPanel`) +5. **Favorites section** (NEW — from `pinnedFlowsStore.items`) +6. **My Flows section** (NEW — paginated, `author_id` filter, view toggle) + +Key implementation details: + +**Favorites section:** +- Source: `usePinnedFlowsStore` items +- Layout: wrapping grid, max 2 rows (~8 cards). "View all favorites" expands if > 8. +- Each card: flow name + type emoji + unpin star button +- Skeleton: 4 pulse placeholder cards while `isLoading` +- Empty: "Star a flow to pin it here for quick access." + +**My Flows section:** +- Source: `treesApi.list({ author_id: user.id, sort_by: 'updated_at', limit: pageSize + 1, skip: (page - 1) * pageSize })` +- Use `usePaginationParams({ defaultPageSize: 10, allowedPageSizes: [10, 25, 50, 'all'] })` +- `hasNextPage = response.length > pageSize; displayItems = response.slice(0, pageSize)` +- For "All": fetch in chunks of 100 up to 500 max +- View toggle: `ViewToggle` bound to `dashboardMyFlowsView` +- Render: `TreeGridView` / `TreeListView` / `TreeTableView` with pin props +- Pagination controls: `Prev` / `Next` / page label / size dropdown +- Skeleton: 6 placeholder cards/rows +- Empty: "You haven't created any flows yet." + "Create your first flow" CTA + +**Remove:** +- The `FiltersBar` (All, Recently Used, My Flows, Team Flows) — replaced by the Favorites + My Flows structure +- The `SectionGroup` "All Flows" wrapper +- The hard-cap of 20 items + +**Step 3: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npm run build` +Expected: Build succeeds with no errors + +**Step 4: Commit** + +```bash +git add frontend/src/pages/QuickStartPage.tsx +git commit -m "feat: dashboard — Favorites grid + paginated My Flows + skeletons + empty states" +``` + +--- + +## Task 12: PinnedFlowsSection — Dual collapse + smooth transitions + +**Files:** +- Modify: `frontend/src/components/sidebar/PinnedFlowsSection.tsx` + +**Step 1: Read the file (already done)** + +**Step 2: Implement dual collapse** + +Add state: +```typescript +const [showAll, setShowAll] = useState(false) +const TRUNCATE_COUNT = 5 +``` + +Modify header collapse to reset truncation: +```typescript +const handleToggleCollapse = () => { + if (collapsed) { + setShowAll(false) // Reset to truncated on re-expand + } + setCollapsed(!collapsed) +} +``` + +Replace the flow list rendering: +```typescript +const visibleFlows = showAll ? flows : flows.slice(0, TRUNCATE_COUNT) +const hasMore = flows.length > TRUNCATE_COUNT +``` + +Add click handler on flow buttons that auto-collapses: +```typescript +onClick={() => { + setShowAll(false) // Collapse back to 5 + navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type)) +}} +``` + +Add "Show more" / "Show less" link after the flow list: +```tsx +{hasMore && !collapsed && ( + +)} +``` + +Add CSS transition on the list container: +```tsx +
+``` + +**Step 3: Verify build** + +Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit` + +**Step 4: Commit** + +```bash +git add frontend/src/components/sidebar/PinnedFlowsSection.tsx +git commit -m "feat: sidebar pinned section — dual collapse + show more/less + smooth transitions" +``` + +--- + +## Task 13: Final build validation + +**Step 1: Full build check** + +Run: `cd /c/Dev/Projects/patherly/frontend && npm run build` +Expected: Build succeeds with zero errors + +**Step 2: Fix any type errors or import issues** + +If build fails, fix issues and amend the relevant commit. + +**Step 3: Commit any final fixes** + +```bash +git add -A +git commit -m "fix: resolve build errors from dashboard implementation" +``` + +--- + +## Summary of files changed + +| # | File | Action | +|---|------|--------| +| 1 | `frontend/src/api/pinnedFlows.ts` | Add `pin()` method | +| 2 | `frontend/src/store/pinnedFlowsStore.ts` | **New** — Zustand pin store | +| 3 | `frontend/src/store/userPreferencesStore.ts` | Add `dashboardMyFlowsView` | +| 4 | `frontend/src/components/layout/Sidebar.tsx` | Use store instead of local state | +| 5 | `frontend/src/hooks/usePaginationParams.ts` | **New** — URL-synced pagination | +| 6 | `frontend/src/hooks/useCachedQuota.ts` | **New** — AI quota cache | +| 7 | `frontend/src/components/library/TreeGridView.tsx` | Optional pin button | +| 8 | `frontend/src/components/library/TreeListView.tsx` | Optional pin button | +| 9 | `frontend/src/components/library/TreeTableView.tsx` | Optional pin column | +| 10 | `frontend/src/pages/TreeLibraryPage.tsx` | Create dropdown + AI Builder + pins | +| 11 | `frontend/src/pages/QuickStartPage.tsx` | Major refactor: Favorites + My Flows | +| 12 | `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Dual collapse | diff --git a/docs/plans/2026-02-20-final-dashboard-plan.md b/docs/plans/2026-02-20-final-dashboard-plan.md new file mode 100644 index 00000000..19c9b614 --- /dev/null +++ b/docs/plans/2026-02-20-final-dashboard-plan.md @@ -0,0 +1,362 @@ +# Dashboard: My Flows, Favorites/Pin UI, and AI Builder in Create Menu + +## Final Implementation Plan (Reviewed & Merged) + +### Decisions Locked + +1. **"My Flows"** = trees where `author_id = currentUser.id` (includes forks, since forks set the forking user as author). +2. **Pagination** = `Prev / Next` with page-size selector (10 / 25 / 50 / All). No numbered total pages — the `/trees` API returns no total count. Page and page size are synced to URL query params (`?page=3&size=25`). +3. **Pin state** = shared Zustand store (`pinnedFlowsStore`), used by Sidebar, Dashboard, and Library. No local state for pins anywhere. **This store owns pin CRUD only — no other state belongs here.** +4. **"Show: All"** = fetches in chunks of 100, capped at 500 items maximum. If ceiling is reached, show message: "Showing first 500 flows. Use search or filters to find specific flows." +5. **Dashboard view preference** = separate key (`dashboardMyFlowsView`) from Library view preference. The two are independent. +6. **Sidebar pinned section** = two independent collapse states: header collapse (hide/show entire section) and list truncation (5 vs. all). When header is collapsed and re-expanded, list resets to 5 items (truncated default). +7. **Max pins** = 15, enforced by backend. Frontend handles 409 conflict with user-facing toast. +8. **AI Builder quota** = cached with 5-minute TTL. Not re-fetched on every page load. +9. **Favorites layout** = compact wrapping grid, max 2 rows (~8 cards visible). "View all" expand link if more than 8 pinned flows. +10. **Loading states** = skeleton loaders for both Favorites and My Flows sections during initial fetch. Meaningful empty states with CTAs for new users. +11. **Accessibility** = all pin/favorite buttons include `aria-label` (dynamic: "Add to favorites" / "Remove from favorites"), `e.stopPropagation()`, and `e.preventDefault()`. + +### Known API Constraints (No Backend Changes) + +- `GET /trees` returns `TreeListItem[]` with no total count metadata. Pagination uses `skip` + `limit` params (max `limit` is 100). +- `POST /trees/{id}/pin`, `DELETE /trees/{id}/pin`, `GET /trees/pinned`, `PATCH /trees/pinned/reorder` all exist and work. +- `pinnedFlowsApi` already has `list()` and `unpin()`. Needs `pin()` added. +- Backend enforces `MAX_PINNED_FLOWS=15` and returns 409 on conflict. + +### Files Modified + +| File | Phase | Change | +|------|-------|--------| +| `frontend/src/api/pinnedFlows.ts` | 1 | Add `pin()` method | +| `frontend/src/store/pinnedFlowsStore.ts` | 1 | **New file.** Zustand store — single source of truth for all pin state | +| `frontend/src/store/userPreferencesStore.ts` | 1 | Add `dashboardMyFlowsView` preference + setter | +| `frontend/src/hooks/usePaginationParams.ts` | 1 | **New file.** Custom hook — reads/writes `page` and `size` URL query params | +| `frontend/src/hooks/useCachedQuota.ts` | 1 | **New file.** Custom hook — fetches AI quota with 5-min TTL cache | +| `frontend/src/components/layout/Sidebar.tsx` | 1 | Replace local pinned state with `pinnedFlowsStore` selectors | +| `frontend/src/components/library/TreeGridView.tsx` | 2 | Add optional pin props + star button with aria-label | +| `frontend/src/components/library/TreeListView.tsx` | 2 | Add optional pin props + star button with aria-label | +| `frontend/src/components/library/TreeTableView.tsx` | 2 | Add optional pin props + star/favorite column with aria-label | +| `frontend/src/pages/TreeLibraryPage.tsx` | 3 | Replace Create link with dropdown menu + AI Builder (cached quota) + wire pin store | +| `frontend/src/pages/QuickStartPage.tsx` | 4 | Major refactor: Favorites grid + paginated My Flows + skeletons + empty states | +| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | 5 | Dual collapse states + reset-on-reexpand + auto-collapse on navigation | + +--- + +### Phase 1: Infrastructure (Stores, Hooks, API) + +**Goal:** Build the shared state and utility layers that every subsequent phase depends on. + +#### 1a. Add `pin()` to API client + +**File:** `frontend/src/api/pinnedFlows.ts` + +```typescript +pin: async (treeId: string) => apiClient.post(`/trees/${treeId}/pin`) +``` + +#### 1b. Create `pinnedFlowsStore` + +**File:** `frontend/src/store/pinnedFlowsStore.ts` (new) + +State: +- `items: PinnedFlow[]` — the pinned flows list +- `isLoaded: boolean` — whether initial fetch has completed +- `isLoading: boolean` — whether initial fetch is in progress +- `isMutatingByTreeId: Record` — per-tree mutation tracking +- `error: string | null` + +Actions: +- `load(force?: boolean)` — fetch from API. Skip if `isLoaded` unless `force=true`. +- `pin(treeId: string)` — optimistic add to `items`, call API, rollback + toast on failure. On 409: toast "Maximum of 15 favorites reached. Unpin a flow to add a new one." and do not add to items. +- `unpin(treeId: string)` — optimistic remove from `items`, call API, rollback + toast on failure. +- `toggle(treeId: string)` — calls `pin` or `unpin` based on current state. + +Derived: +- `isPinned(treeId: string): boolean` +- `pinnedTreeIds: Set` — for passing as prop to view components +- `pinLoadingTreeIds: Set` — derived from `isMutatingByTreeId` for disabling buttons + +**Scope guardrail:** This store owns pin CRUD and derived pin state only. Dashboard layout preferences belong in `userPreferencesStore`. Recently viewed flows or other concerns belong in separate stores/hooks. + +#### 1c. Replace local pin state in Sidebar + +**File:** `frontend/src/components/layout/Sidebar.tsx` + +- Remove local `useState` / `useEffect` for pinned flows (currently mount-only fetch) +- Import and use `usePinnedFlowsStore` selectors and actions +- Call `pinnedFlowsStore.load()` on mount (store handles deduplication) + +#### 1d. Add dashboard view preference + +**File:** `frontend/src/store/userPreferencesStore.ts` + +- New field: `dashboardMyFlowsView: 'grid' | 'list' | 'table'` (default: `'grid'`) +- New setter: `setDashboardMyFlowsView` +- Persisted to localStorage alongside existing preferences +- This is independent from the existing `treeLibraryView` preference + +#### 1e. Create pagination params hook + +**File:** `frontend/src/hooks/usePaginationParams.ts` (new) + +A reusable hook that syncs pagination state to URL query params: + +```typescript +// Usage: +const { page, pageSize, setPage, setPageSize } = usePaginationParams({ + defaultPageSize: 10, + allowedPageSizes: [10, 25, 50, 'all'], +}) +// URL: /dashboard?page=2&size=25 +// Handles: invalid values (falls back to defaults), page reset when size changes +``` + +Behavior: +- Reads `page` and `size` from URL search params on mount +- Falls back to defaults if missing or invalid +- `setPageSize` resets `page` to 1 (changing page size while on page 3 is confusing) +- Validates `page` is a positive integer, `size` is one of the allowed values +- Uses `useSearchParams` from React Router + +#### 1f. Create cached quota hook + +**File:** `frontend/src/hooks/useCachedQuota.ts` (new) + +```typescript +// Usage: +const { aiEnabled, isLoading } = useCachedQuota() +``` + +Behavior: +- On first call, fetches `aiBuilderApi.getQuota()` +- Caches result in module-level variable with timestamp +- Subsequent calls within 5 minutes return cached value (no API call) +- After 5 minutes, re-fetches on next call +- Returns `{ aiEnabled: boolean, isLoading: boolean }` + +--- + +### Phase 2: Pin/Unpin Controls in Library Views + +**Goal:** Add favorite buttons to all three view components without breaking existing behavior. + +**Files:** `TreeGridView.tsx`, `TreeListView.tsx`, `TreeTableView.tsx` + +#### New optional props on all three: + +```typescript +pinnedTreeIds?: Set +onTogglePin?: (treeId: string) => void +pinLoadingTreeIds?: Set +``` + +Props are optional so these components remain backward-compatible with any page that doesn't use pins. + +#### Pin button pattern (all three views): + +```tsx + +``` + +#### View-specific placement: +- **Grid:** Star icon in top-right corner of card +- **List:** Star icon at the end of each row +- **Table:** Dedicated narrow "Favorite" column (leftmost) + +**Critical:** `e.stopPropagation()` + `e.preventDefault()` prevents the click from triggering card/row navigation. Button is disabled (reduced opacity, no pointer events) while that tree's mutation is in-flight. + +--- + +### Phase 3: TreeLibraryPage — Create Menu + Pin Wiring + +**Goal:** Add AI Builder access and connect Library page to shared pin store. + +**File:** `frontend/src/pages/TreeLibraryPage.tsx` + +#### 3a. Replace Create link with dropdown + +Replace the single `` create button with a dropdown menu (same visual pattern as `MyTreesPage`'s `showCreateMenu`). + +Menu items (fixed order): +1. Troubleshooting Tree +2. Procedural Flow +3. Maintenance Flow +4. `` +5. Build with AI *(only shown when `aiEnabled` is true)* + +#### 3b. AI Builder integration + +- Use `useCachedQuota()` hook (from Phase 1f) — no fetch-on-mount, uses cached value +- Import and render `AIFlowBuilderModal` (already built) +- Modal state: `showAIBuilder` boolean, toggled by menu item click + +#### 3c. Wire view components to pin store + +- Import `usePinnedFlowsStore` +- Call `store.load()` on mount +- Pass `pinnedTreeIds`, `onTogglePin: store.toggle`, and `pinLoadingTreeIds` to active view component + +--- + +### Phase 4: QuickStartPage Refactor — Favorites + My Flows + +**Goal:** Transform the dashboard from a flat "All Flows" dump into a structured personal workspace. + +**File:** `frontend/src/pages/QuickStartPage.tsx` + +#### 4a. Favorites Section (above My Flows) + +**Layout:** Compact wrapping grid. Max 2 visible rows (~8 cards). If more than 8 pinned flows, show "View all favorites" link that expands to show all. + +**Data source:** `pinnedFlowsStore.items`, ordered by `display_order`. + +**Each card:** Click to navigate + unpin button (star icon, same pattern as Phase 2). + +**Section header:** "Favorites" with count badge (e.g., "Favorites (7)"). + +**Loading state:** Skeleton loader — 4 placeholder cards with pulse animation, matching the grid layout dimensions so content doesn't shift when data arrives. + +**Empty state:** Subtle message: "Star a flow to pin it here for quick access." No CTA button — the action is contextual (you star flows from the Library or My Flows). + +#### 4b. My Flows Section (replaces "All Flows") + +**Section header:** "My Flows" + +**Data source:** `treesApi.list({ author_id: currentUser.id, sort_by: 'updated_at', skip, limit })` + +**Pagination (via `usePaginationParams` hook):** +- Page size selector: dropdown with 10 (default), 25, 50, All +- For numeric sizes: + ```typescript + // Request one extra item to detect if there's a next page. + // If response.length > pageSize, a next page exists. + // We only display the first `pageSize` items. + const response = await treesApi.list({ + author_id: currentUser.id, + limit: pageSize + 1, + skip: (page - 1) * pageSize, + }); + const hasNextPage = response.length > pageSize; + const displayItems = response.slice(0, pageSize); + ``` +- For "All": fetch in chunks of 100 (`skip=0, limit=100`, then `skip=100`, etc.) until response returns fewer than 100 items OR 500 items total reached. If ceiling hit, show: "Showing first 500 flows. Use search or filters to find specific flows." +- Controls: `Prev` (disabled on page 1) / `Next` (disabled when `!hasNextPage`) / current page label / size dropdown +- URL synced: `?page=2&size=25` — changing page size resets to page 1 + +**View toggle:** Reuse `ViewToggle` component, bound to `dashboardMyFlowsView` preference (independent from Library). + +**Render:** Pass `TreeGridView` / `TreeListView` / `TreeTableView` with pin props from store. + +**Loading state:** Skeleton loader — 6 placeholder cards/rows matching the active view type (grid skeleton for grid view, row skeletons for list/table view). + +**Empty state:** "You haven't created any flows yet." with a CTA button: "Create your first flow" that triggers the Create dropdown menu (same options as TreeLibraryPage: Troubleshooting Tree, Procedural Flow, Maintenance Flow, divider, Build with AI). + +#### 4c. Cleanup + +- Remove the current hard-cap of 20 items +- Remove the "All Flows" `SectionGroup` wrapper +- Keep existing stats cards, recent sessions, and search panels unless explicitly removed later + +--- + +### Phase 5: Sidebar PinnedFlowsSection — Dual Collapse + +**Goal:** Make the sidebar pinned section polished without taking over the sidebar. + +**File:** `frontend/src/components/sidebar/PinnedFlowsSection.tsx` + +#### Two independent collapse states: + +1. **Header collapse:** Click section header → hides/shows entire pinned flows area (existing behavior, keep it). When re-expanding after a collapse, **always reset list truncation to 5 items.** + +2. **List truncation:** When section is expanded: + - Show first 5 pinned flows by default + - "Show more (X)" link at bottom expands to show all (X = total count) + - "Show less" link collapses back to 5 + - Clicking a pinned flow link: navigate AND auto-collapse back to 5 + +#### Smooth transitions: + +- CSS `max-height` transition on the list container: `transition: max-height 250ms ease-out` +- Keep it subtle — no dramatic animations + +--- + +### Test Cases + +#### `pinnedFlowsStore` unit tests: +- `load()` populates `items` from API +- `load()` skips fetch when `isLoaded=true` (unless `force=true`) +- `toggle()` pins an unpinned tree, unpins a pinned tree +- Optimistic update: `items` updates immediately before API resolves +- Rollback: `items` reverts if API call fails +- 409 conflict: shows error toast, does not add to `items` +- Sidebar + Dashboard selectors reflect same state after mutation +- `pinnedTreeIds` derived set updates correctly + +#### `usePaginationParams` hook tests: +- Reads `page` and `size` from URL on mount +- Falls back to defaults when URL params missing or invalid +- `setPageSize` resets page to 1 +- Invalid page (negative, zero, non-number) falls back to 1 + +#### `useCachedQuota` hook tests: +- First call fetches from API +- Second call within 5 minutes returns cached value (no API call) +- Call after 5 minutes re-fetches + +#### `PinnedFlowsSection` component tests: +- Shows max 5 items by default +- "Show more" reveals all items +- Clicking a flow collapses list back to 5 +- Header collapse hides entire section +- Re-expanding after header collapse resets to 5 items + +#### `QuickStartPage` integration tests: +- My Flows uses `author_id` filter +- Numeric page size: requests `limit=size+1`, displays `size` results +- "All" fetches iteratively, stops at 500 ceiling +- Favorites section updates immediately after pin/unpin +- Empty state shows CTA when user has zero flows +- Skeleton loaders appear during fetch +- URL params update when page/size changes + +#### `TreeLibraryPage` tests: +- Create dropdown renders all flow type options +- "Build with AI" only shown when `aiEnabled=true` +- "Build with AI" opens `AIFlowBuilderModal` +- Pin buttons work and sync with store + +#### Regression: +- `cd frontend && npm run test` +- `cd frontend && npm run build` + +### Manual Verification Checklist + +- [ ] Dashboard: Favorites section shows pinned flows in compact grid, My Flows shows paginated authored flows +- [ ] Pin a flow on Library page → appears in Dashboard Favorites AND Sidebar immediately (no navigation) +- [ ] Unpin from Dashboard → removed from Sidebar immediately +- [ ] Page size dropdown: 10/25/50/All all work, Prev/Next show/hide correctly +- [ ] "Show All" stops at 500 items with message if ceiling hit +- [ ] Change page → URL updates to `?page=X&size=Y`. Refresh → same page/size restored. +- [ ] View toggle on Dashboard is independent from Library view toggle +- [ ] Sidebar: max 5 shown, "Show more" expands, clicking a flow collapses and navigates +- [ ] Collapse sidebar section → re-expand → list is back to 5 (not "show all") +- [ ] Try to pin a 16th flow → toast about max limit, flow not pinned +- [ ] AI Builder: Library page "Create New" → "Build with AI" opens modal (uses cached quota) +- [ ] New user with zero flows: sees empty state with "Create your first flow" CTA +- [ ] New user with zero favorites: sees "Star a flow to pin it here" message +- [ ] During initial load: skeleton placeholders visible, no layout shift when data arrives +- [ ] Pin button: screen reader announces "Add to favorites" / "Remove from favorites" +- [ ] Build passes: `cd frontend && npm run build` with no errors