feat: remove workspace system, add pinned flows and label renames

Replace workspace system with pinned flows API (pin/unpin/list/reorder).
Rename user-facing labels: Tree→Flow, Procedure→Project. Add sidebar
nav sub-items for flow type filtering. Remove 11 workspace files,
add migrations 037-038, clean all workspace references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-15 12:06:10 -05:00
parent 336e37d018
commit 58a55f4d88
39 changed files with 1612 additions and 2288 deletions

View File

@@ -24,10 +24,10 @@
- **Logo:** Inline SVG in `BrandLogo.tsx` (decision-tree icon with gradient). Wordmark: "Resolution" in white + "Flow" in `text-gradient-brand`
- **Brand assets:** `brand-assets/` (source SVGs + brand-guide.html), `frontend/src/assets/brand/` (app assets), `frontend/public/icons/` (favicon)
- **CSS utilities:** `text-gradient-brand`, `bg-gradient-brand`, `bg-gradient-brand-hover` (defined in `tailwind.config.js` and `index.css`)
- **Layout:** App shell with persistent sidebar + top bar + main workspace (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Workspace system:** Top-level context switcher (Troubleshooting, Procedures, Policies, Finance). Sidebar categories, tags, stats, and content adapt per workspace. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Layout:** App shell with persistent sidebar + top bar + main content (CSS Grid). See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Navigation:** Sidebar nav with type sub-items (All Flows → Troubleshooting / Projects). Pinned flows section for quick access. NO workspace switcher. See [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md)
- **Terminology:** User-facing label is "Flows" (not "Trees"). Procedural flows are called "Projects" in the UI. `tree_type` column values unchanged in DB.
- **Rebrand guide:** [REBRAND-IMPLEMENTATION-GUIDE.md](REBRAND-IMPLEMENTATION-GUIDE.md)
- **Interactive mockup:** `docs/mockups/resolutionflow-workspaces-mockup.html` (open in browser for visual reference)
**Component styling rules:**
- Primary buttons: `bg-gradient-brand` with `shadow-lg shadow-primary/20`, hover lifts with stronger shadow
@@ -40,7 +40,7 @@
- Cards: `bg-card border-border rounded-xl`, hover brightens border
- Section labels: `font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground`
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, workspace context, and component specs.
When adding new pages/components: use "ResolutionFlow" branding, purple gradient accent theme, `bg-card` containers, `text-foreground`/`text-muted-foreground` hierarchy. Primary actions use `bg-gradient-brand`. Pages render inside the app shell (CSS Grid: topbar + sidebar + main). Use "Flows" not "Trees" in all user-facing text; use "Projects" not "Procedures" for procedural flows. Reference [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) for layout patterns, navigation, and component specs.
---

File diff suppressed because it is too large Load Diff

608
WORKSPACE-REMOVAL-PLAN.md Normal file
View File

@@ -0,0 +1,608 @@
# Workspace Removal & Navigation Refactor — Implementation Plan
> **Purpose:** Combined implementation plan for removing the workspace system, renaming UI labels (Trees→Flows, Procedures→Projects), adding pinned flows, and restructuring sidebar navigation.
> **Source of Truth:** [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) v2
> **Date:** February 15, 2026
> **Tailwind Version:** v3 only — do not use v4 syntax or patterns (see `package.json` line 56)
---
## Why This Change
Workspaces added unnecessary cognitive overhead for the target MSP audience. UX research (Hick's Law, context-switching studies) shows that at the current product scale (10-15 beta testers, <50 flows per account), a workspace switcher creates friction without organizational benefit. The replacement is a flat navigation model with type sub-items and pinned favorites.
---
## Phase 1 — Backend: Add Pinned Flows (ship BEFORE removing workspaces)
> **Important sequencing:** Add the pinned flows feature first and verify it works. Then remove workspaces in Phase 2. This ensures the replacement feature is stable before tearing out the old one. If both are done in the same session, at minimum run the pinned flows tests before starting workspace removal.
### 1a. Add Pinned Flows Table
```bash
cd backend
alembic revision --autogenerate -m "add_user_pinned_trees"
```
```sql
CREATE TABLE user_pinned_trees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE,
display_order INTEGER NOT NULL DEFAULT 0,
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_user_pinned_tree UNIQUE (user_id, tree_id)
);
CREATE INDEX idx_user_pinned_trees_user ON user_pinned_trees(user_id);
CREATE INDEX idx_user_pinned_trees_tree ON user_pinned_trees(tree_id);
```
### 1b. Pinned Flows API Contract
**Endpoints:**
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/api/v1/trees/pinned` | List user's pinned flows (ordered by display_order) | Required |
| `POST` | `/api/v1/trees/{id}/pin` | Pin a flow to sidebar | Required |
| `DELETE` | `/api/v1/trees/{id}/pin` | Unpin a flow from sidebar | Required |
| `PATCH` | `/api/v1/trees/pinned/reorder` | Update display_order for all pinned flows | Required |
**Constraints & Limits:**
- Max 15 pinned flows per user (return 409 if exceeded)
- `UNIQUE(user_id, tree_id)` — pinning an already-pinned flow returns 200 (idempotent), not an error
- Unpinning an already-unpinned flow returns 200 (idempotent)
- When a tree is deleted, cascade removes the pin automatically (FK ON DELETE CASCADE)
- Pins are per-user, not per-account — each user has their own pinned set
**Response Shapes:**
```typescript
// GET /api/v1/trees/pinned
interface PinnedFlowsResponse {
items: PinnedFlow[];
count: number;
}
interface PinnedFlow {
id: string; // pin record id
tree_id: string;
tree_name: string;
tree_type: 'troubleshooting' | 'procedural';
category_emoji?: string;
category_name?: string;
pinned_at: string; // ISO datetime
display_order: number;
}
// POST /api/v1/trees/{id}/pin → returns PinnedFlow
// DELETE /api/v1/trees/{id}/pin → returns { success: true }
// PATCH /api/v1/trees/pinned/reorder
// body: { order: [{ tree_id: string, display_order: number }] }
// returns: PinnedFlowsResponse
```
**Error Codes:**
- `404` — tree not found or user lacks access
- `409` — max pins reached (15)
- `200` — idempotent success (pin already exists / already unpinned)
### 1c. Add Pinned Flows Backend Files
```
CREATE: backend/app/models/user_pinned_tree.py
MODIFY: backend/app/models/__init__.py — add UserPinnedTree import
MODIFY: backend/app/api/endpoints/trees.py — add pin/unpin/list-pinned/reorder endpoints
MODIFY: backend/app/schemas/tree.py — add PinnedFlow schema, add is_pinned to TreeListItem
MODIFY: backend/app/api/router.py — register pin routes (nested under trees router)
```
**Verify pinned flows work before proceeding:**
```bash
cd backend
alembic upgrade head
pytest tests/ -k "pin" -v # Run pin-related tests
# Manual: POST a pin, GET pinned list, verify response shapes
```
---
## Phase 2 — Backend: Remove Workspace System
### 2a. New Forward Migration (DO NOT use downgrade path)
> **⚠️ Critical:** The existing `036_add_workspaces.py` downgrade function drops `tree_categories.color`, which we want to keep. Create a new forward migration instead.
```bash
cd backend
alembic revision --autogenerate -m "remove_workspace_system"
```
The migration must:
```python
def upgrade():
# 1. Drop workspace_id FK from trees (if column exists)
op.drop_constraint('trees_workspace_id_fkey', 'trees', type_='foreignkey')
op.drop_column('trees', 'workspace_id')
# 2. Drop workspace_id FK from tree_categories (if column exists)
op.drop_constraint('tree_categories_workspace_id_fkey', 'tree_categories', type_='foreignkey')
op.drop_column('tree_categories', 'workspace_id')
# 3. Drop workspaces table
op.drop_table('workspaces')
# DO NOT drop tree_categories.color — we still use it
def downgrade():
# Recreate workspaces table and FKs if needed (reverse of above)
pass
```
### 2b. Delete Workspace Backend Files
```
DELETE: backend/app/api/endpoints/workspaces.py
DELETE: backend/app/models/workspace.py
DELETE: backend/app/schemas/workspace.py (if exists)
```
### 2c. Clean Up Orphaned Workspace References
> **⚠️ Important:** Workspace references exist beyond the obvious files. Scrub these:
| File | What to Remove |
|------|---------------|
| `backend/app/api/router.py` | Remove workspace route registration |
| `backend/app/models/__init__.py` (line 23, 55) | Remove `Workspace` import and `__all__` entry |
| `backend/app/models/account.py` (line 18, 49) | Remove `workspaces` relationship on Account model |
| `backend/app/models/category.py` (line 40) | Remove `workspace_id` column if present |
| `backend/app/models/tree.py` | Remove `workspace_id` column and relationship if present |
**Verification:**
```bash
cd backend
grep -r "workspace" app/ --include="*.py" -l
# Should only return this plan file and alembic migration history — no active code
pytest --override-ini="addopts="
# All tests must pass
```
**Migration smoke test (run against BOTH clean and existing DB):**
```bash
# Test on existing DB with workspace data:
alembic upgrade head
# Verify no errors, workspace tables gone, tree_categories.color still exists
# Test on clean DB (full migration chain):
dropdb patherly_test && createdb patherly_test
DATABASE_URL=postgresql://...patherly_test alembic upgrade head
# Verify clean run through all migrations
```
---
## Phase 3 — Frontend: Remove Workspace System
### 3a. Move sidebarCollapsed State First
The `sidebarCollapsed` state currently lives in `workspaceStore.ts`. Move it before deleting:
```typescript
// frontend/src/store/userPreferencesStore.ts — ADD these:
sidebarCollapsed: boolean;
toggleSidebar: () => void;
// Implementation:
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
toggleSidebar: () => {
const next = !get().sidebarCollapsed;
localStorage.setItem('sidebar-collapsed', String(next));
set({ sidebarCollapsed: next });
},
```
> Note: `userPreferencesStore` already uses Zustand persist — verify the localStorage key name doesn't conflict.
### 3b. Delete Workspace Frontend Files
```
DELETE: frontend/src/store/workspaceStore.ts
DELETE: frontend/src/components/workspace/WorkspaceSwitcher.tsx
DELETE: frontend/src/components/workspace/WorkspaceCreateModal.tsx
DELETE: frontend/src/constants/workspaceLabels.ts
DELETE: frontend/src/types/workspace.ts (or remove Workspace type if shared file)
DELETE: frontend/src/api/workspaces.ts
DELETE: docs/mockups/resolutionflow-workspaces-mockup.html
```
**Before deleting `workspace/` directory**, move these components:
```
MOVE: frontend/src/components/workspace/CategoryList.tsx → frontend/src/components/sidebar/CategoryList.tsx
MOVE: frontend/src/components/workspace/TagCloud.tsx → frontend/src/components/sidebar/TagCloud.tsx
```
### 3c. Update Shell Files That Import Workspace Store
> **⚠️ Important:** These files are marked "keep" in the design system doc but they currently import workspace code. Each needs internal refactoring:
| File | Lines to Change | Action |
|------|----------------|--------|
| `AppLayout.tsx` (line 6, 17) | `import { useWorkspaceStore }` | Replace with `import { useUserPreferencesStore }` — use `sidebarCollapsed` and `toggleSidebar` from there |
| `TopBar.tsx` (line 6, 20) | `import { useWorkspaceStore }` | Replace with `useUserPreferencesStore` for `sidebarCollapsed`. Remove `getActiveWorkspace()` and `labels` — use static labels or `getFlowLabels()` |
| `Sidebar.tsx` (line 4, 111+) | `WorkspaceSwitcher` import and render | Remove workspace switcher component. Add pinned flows section and nav sub-items (Phase 4) |
### 3d. Clean Up Orphaned Frontend References
> **⚠️ Important:** Additional workspace references exist beyond the obvious files:
| File | What to Change |
|------|---------------|
| `frontend/src/types/index.ts` (line 11, 14) | Remove `Workspace` type export |
| `frontend/src/components/layout/QuickLaunch.tsx` | Replace workspace-dependent labels with static "New Flow" / "New Project" |
| `frontend/src/components/layout/CommandPalette.tsx` | Replace workspace-dependent search placeholder and result labels |
**Verification:**
```bash
cd frontend
grep -r "workspace" src/ --include="*.ts" --include="*.tsx" -l
# Should return nothing except possibly test files or comments
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
# Must return nothing
npm run build
# Must compile clean with zero errors
```
---
## Phase 4 — Label Renames (Repo-Wide Audit)
### 4a. Create Flow Type Labels
```typescript
// frontend/src/constants/flowLabels.ts (NEW — replaces workspaceLabels.ts)
export interface FlowTypeLabels {
navLabel: string;
singular: string;
plural: string;
newButton: string;
searchPlaceholder: string;
icon: string;
}
export const FLOW_TYPE_LABELS: Record<string, FlowTypeLabels> = {
all: {
navLabel: 'All Flows',
singular: 'Flow',
plural: 'Flows',
newButton: '+ Create Flow',
searchPlaceholder: 'Search flows, sessions, tags…',
icon: '📦',
},
troubleshooting: {
navLabel: 'Troubleshooting',
singular: 'Flow',
plural: 'Flows',
newButton: '+ New Troubleshooting Flow',
searchPlaceholder: 'Search troubleshooting flows…',
icon: '🔧',
},
procedural: {
navLabel: 'Projects',
singular: 'Project',
plural: 'Projects',
newButton: '+ New Project',
searchPlaceholder: 'Search projects, runbooks…',
icon: '📋',
},
};
export function getFlowLabels(typeFilter?: string): FlowTypeLabels {
if (typeFilter && typeFilter in FLOW_TYPE_LABELS) {
return FLOW_TYPE_LABELS[typeFilter];
}
return FLOW_TYPE_LABELS.all;
}
```
### 4b. Label Audit — Files Requiring Changes
Every instance of old terminology must be found and replaced. This is a **repo-wide pass**, not a targeted find-replace.
**User-facing label changes:**
| Old Label | New Label | Notes |
|-----------|-----------|-------|
| "All Trees" | "All Flows" | Sidebar nav, page titles |
| "Tree Editor" | "Flow Editor" | Sidebar nav |
| "New Tree" | "Create Flow" | Buttons, menus |
| "All Procedures" | "Projects" | Sub-nav item |
| "New Procedure" | "New Project" | Buttons, menus |
| "Procedure" (singular) | "Project" | Throughout UI |
**Known files with old labels (non-exhaustive):**
| File | Old Text | New Text |
|------|----------|----------|
| `Sidebar.tsx` | "All Trees", "Tree Editor" | "All Flows", "Flow Editor" |
| `TreeLibraryPage.tsx` (line 279, 298) | "All Trees", "Tree" references | "All Flows", "Flow" |
| `QuickStartPage.tsx` (line 150) | Workspace/procedure labels | Flow/Project labels |
| `QuickLaunch.tsx` | "New Tree", "New Procedure" | "Create Flow", "New Project" |
| `CommandPalette.tsx` | Search labels | Flow-based labels |
| `TopBar.tsx` | Search placeholder | Use `getFlowLabels()` or static "Search flows, sessions, tags…" |
**Catch-all verification:**
```bash
cd frontend
# Find any remaining user-facing "Tree" or "Procedure" labels (excluding variable names and imports)
grep -rn '".*Tree.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|interface\|type \|treesApi\|tree_type\|treeId\|TreeNav\|TreeGrid\|TreeList\|TreeTable"
grep -rn '".*Procedure.*"' src/ --include="*.tsx" --include="*.ts" | grep -v "import\|//\|type "
```
> **Important:** Only rename **user-facing strings** (UI text, placeholder text, toast messages, page titles). Do NOT rename: variable names (`treesApi`, `TreeListItem`), route paths (`/trees`), database columns (`tree_type`), or API endpoints (`/api/v1/trees`). Internal code can say "tree" — users never see it.
### 4c. Acceptance Criteria for Label Audit
- [ ] No user-visible text says "Tree" (except proper nouns or technical docs)
- [ ] No user-visible text says "Procedure" — all say "Project"
- [ ] Search bar placeholder says "Search flows, sessions, tags…"
- [ ] Page title on library page says "Flow Library"
- [ ] "+ Create Flow" button on library page (or "+ New Project" when filtered to projects)
- [ ] Sidebar nav says "All Flows", "Flow Editor"
- [ ] Empty states use "flow" / "project" language
- [ ] Toast messages use "flow" / "project" language
---
## Phase 5 — Sidebar: Nav Sub-Items & Pinned Flows UI
### 5a. Extend NavItem for Children
```tsx
// frontend/src/components/layout/NavItem.tsx — extend props
interface NavItemProps {
href: string;
icon: LucideIcon;
label: string;
badge?: number | 'dot';
isActive?: boolean;
children?: NavSubItem[]; // NEW
}
interface NavSubItem {
href: string;
label: string;
count?: number;
isActive?: boolean;
}
```
**Sub-item rendering:**
- Indented `pl-9` (past parent icon)
- No icon, just text + optional count badge
- Font: `text-[0.8125rem] text-muted-foreground`, active: `text-foreground`
- Active: `bg-[var(--sidebar-active)]` but without the left gradient bar (only parent gets that)
- Sub-items always visible (not collapsible) — there are only 2
### 5b. Sidebar Structure
```
── PINNED ─────────────────── (collapsible section)
📧 Email Delivery Issues (click → start session or go to flow)
🔒 AD Account Lockout
👤 New User Onboarding
───────────────────────────────
📊 Dashboard
📦 All Flows 47
🔧 Troubleshooting 29
📋 Projects 18
✏️ Flow Editor
⏱️ Sessions 4
📄 Exports
📚 Step Library •
───────────────────────────────
CATEGORIES
● Networking 12
● Active Directory 8
● Email 11
───────────────────────────────
POPULAR TAGS
[vpn] [dns] [exchange] [onboarding]
═══════════════════════════════
👥 Team
⚙️ Settings
```
**Behavior:**
- Clicking "All Flows" → `/trees` (no type filter)
- Clicking "Troubleshooting" → `/trees?type=troubleshooting`
- Clicking "Projects" → `/trees?type=procedural`
- When a sub-item is active, parent "All Flows" stays highlighted (dimmer state)
- Badge counts update based on actual tree counts by type
### 5c. Pinned Flows Section
```tsx
// frontend/src/components/sidebar/PinnedFlowsSection.tsx (NEW)
interface PinnedFlowsSectionProps {
flows: PinnedFlow[];
onFlowClick: (treeId: string) => void;
onUnpin: (treeId: string) => void;
}
```
**Behavior:**
- Each pinned item: emoji + name (truncated with ellipsis) + hover reveals quick-start button
- Right-click context menu: "Start Session", "Edit Flow", "Unpin from Sidebar"
- Empty state: "⭐ Pin your most-used flows here" in `text-xs text-muted-foreground`
- Section header has collapse chevron
- Max 15 items shown; section scrolls internally if needed
- Drag-to-reorder (calls `PATCH /api/v1/trees/pinned/reorder`)
### 5d. Pinned Flows Frontend Wiring
```
CREATE: frontend/src/api/pinnedFlows.ts — pinTree(id), unpinTree(id), listPinned(), reorderPinned(order)
CREATE: frontend/src/components/sidebar/PinnedFlowsSection.tsx
MODIFY: frontend/src/types/tree.ts (or index.ts) — add is_pinned?: boolean to TreeListItem, add PinnedFlow type
MODIFY: frontend/src/api/trees.ts — add is_pinned to list response handling
MODIFY: frontend/src/components/layout/Sidebar.tsx — import and render PinnedFlowsSection above nav
MODIFY: frontend/src/pages/TreeLibraryPage.tsx — add pin star to flow cards (visible on hover, filled if pinned)
MODIFY: flow card three-dot menu — add "Pin to Sidebar" / "Unpin from Sidebar" action
```
**Pin/unpin interaction:**
- Toast on pin: "📌 Pinned **{name}** to sidebar"
- Toast on unpin: "Unpinned **{name}**"
- Library flow cards show subtle star icon on hover; filled star if pinned
- Pin star click calls API, optimistically updates UI
---
## Phase 6 — Library Page Cleanup
### 6a. Remove Folder Sidebar Panel
> **⚠️ Important:** `TreeLibraryPage.tsx` has deep folder state dependencies (lines 9, 34, 261+). This is not a simple component removal.
**What to remove:**
- `FolderSidebar` component import and rendering (the persistent left panel)
- `FolderEditModal` import and state
- The CSS column that gives FolderSidebar its own grid track
- `mobileFolderOpen` state
**What to KEEP:**
- `selectedFolderId` state — keep for now, wire to a future "Filter by Folder" dropdown
- `folders` state and `foldersApi.list()` call — keep data available
- `FolderSidebar.tsx` and `FolderEditModal.tsx` files — do not delete, just stop rendering them
- All folder-related backend code (models, API, database tables) — untouched
**Replacement UX (deferred but noted):**
- Future: "Filter by Folder" dropdown in the filters bar, or "Move to Folder" in the three-dot menu
- For now: folder filtering is simply not visible in the UI. The data model and API remain intact.
**Layout change:**
- Library page becomes full-width within the main content area (no second sidebar column)
- Grid goes from `sidebar | folders | content``sidebar | content`
### 6b. Verification
```bash
cd frontend && npm run build
# Must compile clean
```
Manual checks:
- [ ] Library page is full-width (no left folder panel)
- [ ] No JavaScript errors in browser console related to folder state
- [ ] Category filtering from sidebar clicks still works
- [ ] Tag filtering from sidebar clicks still works
---
## Complete File Manifest
### Delete (11 files)
| File | Reason |
|------|--------|
| `frontend/src/store/workspaceStore.ts` | Replaced by userPreferencesStore (sidebarCollapsed) |
| `frontend/src/components/workspace/WorkspaceSwitcher.tsx` | Feature removed |
| `frontend/src/components/workspace/WorkspaceCreateModal.tsx` | Feature removed |
| `frontend/src/constants/workspaceLabels.ts` | Replaced by flowLabels.ts |
| `frontend/src/types/workspace.ts` | Type no longer needed |
| `frontend/src/api/workspaces.ts` | API removed |
| `backend/app/api/endpoints/workspaces.py` | API removed |
| `backend/app/models/workspace.py` | Model removed |
| `backend/app/schemas/workspace.py` | Schema removed (if exists) |
| `docs/mockups/resolutionflow-workspaces-mockup.html` | Outdated mockup |
### Move (2 files)
| From | To |
|------|----|
| `frontend/src/components/workspace/CategoryList.tsx` | `frontend/src/components/sidebar/CategoryList.tsx` |
| `frontend/src/components/workspace/TagCloud.tsx` | `frontend/src/components/sidebar/TagCloud.tsx` |
Then delete the empty `frontend/src/components/workspace/` directory.
### Create (6+ files)
| File | Purpose |
|------|---------|
| `backend/alembic/versions/0XX_remove_workspace_system.py` | Drop workspace tables/columns |
| `backend/alembic/versions/0XX_add_user_pinned_trees.py` | New pinned flows table |
| `backend/app/models/user_pinned_tree.py` | UserPinnedTree SQLAlchemy model |
| `frontend/src/constants/flowLabels.ts` | Flow type label constants |
| `frontend/src/api/pinnedFlows.ts` | Pin/unpin API client |
| `frontend/src/components/sidebar/PinnedFlowsSection.tsx` | Pinned flows sidebar component |
### Modify (14 files — key changes only)
| File | Changes |
|------|---------|
| `backend/app/api/router.py` | Remove workspace routes, add pin routes |
| `backend/app/models/__init__.py` | Remove Workspace import (line 23, 55), add UserPinnedTree |
| `backend/app/models/account.py` | Remove `workspaces` relationship (line 18, 49) |
| `backend/app/models/category.py` | Remove `workspace_id` column (line 40) if present |
| `backend/app/models/tree.py` | Remove `workspace_id` column/relationship if present |
| `backend/app/api/endpoints/trees.py` | Add pin/unpin/list-pinned/reorder endpoints |
| `backend/app/schemas/tree.py` | Add PinnedFlow schema, add `is_pinned` to tree list |
| `frontend/src/store/userPreferencesStore.ts` | Absorb `sidebarCollapsed` + `toggleSidebar` |
| `frontend/src/components/layout/AppLayout.tsx` | Replace workspaceStore with userPreferencesStore |
| `frontend/src/components/layout/TopBar.tsx` | Replace workspaceStore, static search labels |
| `frontend/src/components/layout/Sidebar.tsx` | Remove workspace switcher, add pinned section + nav sub-items |
| `frontend/src/components/layout/NavItem.tsx` | Add `children` sub-item support |
| `frontend/src/pages/TreeLibraryPage.tsx` | Remove FolderSidebar, update labels, add pin star |
| `frontend/src/components/layout/QuickLaunch.tsx` | Update action labels |
| `frontend/src/components/layout/CommandPalette.tsx` | Update search labels |
| `frontend/src/pages/QuickStartPage.tsx` | Update workspace/procedure labels (line 150) |
| `frontend/src/types/index.ts` | Remove Workspace export (line 11, 14), add PinnedFlow |
---
## Verification Checklist
### Automated
```bash
# Backend
cd backend
alembic upgrade head # Migration applies clean
pytest --override-ini="addopts=" # All tests pass
grep -r "workspace" app/ --include="*.py" -l # No active workspace refs
# Frontend
cd frontend
npm run build # Compiles clean, zero errors
grep -r "workspaceStore\|WorkspaceSwitcher\|workspacesApi\|workspaceLabels" src/ -l
# Returns nothing
```
### Manual UI Checks
- [ ] Sidebar shows "All Flows" with "Troubleshooting" and "Projects" sub-items — no workspace switcher
- [ ] Clicking "Troubleshooting" filters library to `?type=troubleshooting`
- [ ] Clicking "Projects" filters library to `?type=procedural`
- [ ] Clicking "All Flows" removes type filter
- [ ] Sub-item counts reflect actual flow counts per type
- [ ] Pin a flow from the library card three-dot menu → appears in sidebar "PINNED" section
- [ ] Unpin a flow → disappears from sidebar
- [ ] Pinned flow click navigates to that flow
- [ ] Library page is full-width (no folder sidebar panel)
- [ ] Search bar is centered in top bar
- [ ] Keyboard shortcut shows "Ctrl+K" on Windows, "⌘K" on Mac
- [ ] All user-visible labels say "Flow" / "Project", never "Tree" / "Procedure"
- [ ] Empty states use correct terminology
- [ ] Toast messages use correct terminology
- [ ] Command palette search works with new labels
- [ ] Quick Launch actions show correct labels

View File

@@ -0,0 +1,40 @@
"""Add user_pinned_trees table
Revision ID: 037
Revises: 036
Create Date: 2026-02-15
Adds pinned flows feature for sidebar favorites.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '037'
down_revision: Union[str, None] = '036'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'user_pinned_trees',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('pinned_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
)
op.create_index('idx_user_pinned_trees_user', 'user_pinned_trees', ['user_id'])
op.create_index('idx_user_pinned_trees_tree', 'user_pinned_trees', ['tree_id'])
def downgrade() -> None:
op.drop_index('idx_user_pinned_trees_tree', 'user_pinned_trees')
op.drop_index('idx_user_pinned_trees_user', 'user_pinned_trees')
op.drop_table('user_pinned_trees')

View File

@@ -0,0 +1,69 @@
"""Remove workspace system
Revision ID: 038
Revises: 037
Create Date: 2026-02-15
Drops workspace tables and columns. Keeps tree_categories.color.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '038'
down_revision: Union[str, None] = '037'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Drop workspace_id FK and column from trees
op.drop_index('ix_trees_workspace_id', 'trees')
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
op.drop_column('trees', 'workspace_id')
# 2. Drop workspace_id FK and column from tree_categories
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
op.drop_column('tree_categories', 'workspace_id')
# 3. Drop workspaces table
op.drop_index('ix_workspaces_account_id', 'workspaces')
op.drop_index('ix_workspaces_slug', 'workspaces')
op.drop_table('workspaces')
# DO NOT drop tree_categories.color — we still use it
def downgrade() -> None:
# Recreate workspaces table
op.create_table(
'workspaces',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('name', sa.String(100), nullable=False),
sa.Column('slug', sa.String(100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(10), nullable=True),
sa.Column('accent_color', sa.String(7), nullable=True),
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
# Re-add workspace_id columns
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])

View File

@@ -17,8 +17,10 @@ from app.models.folder import UserFolder, user_folder_trees
from app.schemas.tree import (
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError,
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
)
from app.models.user_pinned_tree import UserPinnedTree
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
from app.core.permissions import can_edit_tree, can_access_tree
from app.core.filters import build_tree_access_filter
@@ -1030,3 +1032,185 @@ async def check_tree_can_publish(
can_publish=can_publish,
errors=[ValidationError(**error) for error in validation_errors]
)
# --- Pinned Flows Endpoints ---
MAX_PINNED_FLOWS = 15
@router.get("/pinned", response_model=PinnedFlowsListResponse)
async def list_pinned_flows(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""List user's pinned flows, ordered by display_order."""
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
async def pin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Pin a flow to the user's sidebar."""
# Check tree exists and user can access it
tree_result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel))
.where(Tree.id == tree_id, Tree.is_active == True)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree")
# Check if already pinned (idempotent)
existing = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = existing.scalar_one_or_none()
if pin:
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
# Check max pins
count_result = await db.execute(
select(func.count(UserPinnedTree.id)).where(
UserPinnedTree.user_id == current_user.id
)
)
count = count_result.scalar() or 0
if count >= MAX_PINNED_FLOWS:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Maximum of {MAX_PINNED_FLOWS} pinned flows reached"
)
# Create pin
pin = UserPinnedTree(
user_id=current_user.id,
tree_id=tree_id,
display_order=count, # Append at end
)
db.add(pin)
await db.commit()
await db.refresh(pin)
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
@router.delete("/{tree_id}/pin")
async def unpin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Unpin a flow from the user's sidebar."""
result = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = result.scalar_one_or_none()
if pin:
await db.delete(pin)
await db.commit()
return {"success": True}
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
async def reorder_pinned_flows(
reorder_data: PinnedFlowReorderRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update display_order for all pinned flows."""
for item in reorder_data.order:
await db.execute(
update(UserPinnedTree)
.where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == item.tree_id
)
.values(display_order=item.display_order)
)
await db.commit()
# Return updated list
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))

View File

@@ -1,154 +0,0 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.workspace import Workspace
from app.models.tree import Tree
from app.models.user import User
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
from app.api.deps import get_current_active_user
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
@router.get("", response_model=list[WorkspaceResponse])
async def list_workspaces(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""List all workspaces for the user's account."""
if not current_user.account_id:
return []
# Get workspaces with tree counts
query = (
select(
Workspace,
func.count(Tree.id).label("tree_count")
)
.outerjoin(Tree, (Tree.workspace_id == Workspace.id) & (Tree.deleted_at.is_(None)))
.where(Workspace.account_id == current_user.account_id)
.group_by(Workspace.id)
.order_by(Workspace.sort_order, Workspace.name)
)
result = await db.execute(query)
rows = result.all()
return [
WorkspaceResponse(
**{c.key: getattr(ws, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
for ws, tree_count in rows
]
@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
data: WorkspaceCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Create a new workspace."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account found")
# Check slug uniqueness within account
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == data.slug
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
workspace = Workspace(
name=data.name,
slug=data.slug,
description=data.description,
icon=data.icon,
accent_color=data.accent_color,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(workspace)
await db.flush()
await db.commit()
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=0
)
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: UUID,
data: WorkspaceUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Update a workspace."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
update_data = data.model_dump(exclude_unset=True)
if "slug" in update_data:
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == update_data["slug"],
Workspace.id != workspace_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
for key, value in update_data.items():
setattr(workspace, key, value)
await db.commit()
# Get tree count
count_result = await db.execute(
select(func.count(Tree.id)).where(
Tree.workspace_id == workspace_id,
Tree.deleted_at.is_(None)
)
)
tree_count = count_result.scalar() or 0
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Delete a workspace. Trees are unassigned, not deleted."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
if workspace.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default workspace")
# Unassign trees (set workspace_id to NULL)
await db.execute(
Tree.__table__.update()
.where(Tree.workspace_id == workspace_id)
.values(workspace_id=None)
)
await db.delete(workspace)
await db.commit()

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown, workspaces
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -25,4 +25,3 @@ api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(workspaces.router)

View File

@@ -20,7 +20,7 @@ from .session_share import SessionShare, SessionShareView
from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from .platform_setting import PlatformSetting
from .workspace import Workspace
from .user_pinned_tree import UserPinnedTree
__all__ = [
"User",
@@ -52,5 +52,5 @@ __all__ = [
"PlanFeatureDefault",
"AccountFeatureOverride",
"PlatformSetting",
"Workspace",
"UserPinnedTree",
]

View File

@@ -15,7 +15,6 @@ if TYPE_CHECKING:
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
from app.models.workspace import Workspace
class Account(Base):
@@ -46,4 +45,3 @@ class Account(Base):
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
workspaces: Mapped[list["Workspace"]] = relationship("Workspace", back_populates="account")

View File

@@ -11,7 +11,6 @@ if TYPE_CHECKING:
from app.models.team import Team
from app.models.account import Account
from app.models.user import User
from app.models.workspace import Workspace
class TreeCategory(Base):
@@ -52,13 +51,6 @@ class TreeCategory(Base):
String(7), nullable=True, default='#3b82f6',
comment="Hex color for category dot indicator"
)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this category belongs to"
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -79,7 +71,6 @@ class TreeCategory(Base):
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="categories")
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel")
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="categories")
@property
def is_global(self) -> bool:

View File

@@ -15,7 +15,6 @@ if TYPE_CHECKING:
from app.models.tag import TreeTag
from app.models.folder import UserFolder
from app.models.tree_share import TreeShare
from app.models.workspace import Workspace
class Tree(Base):
@@ -121,14 +120,6 @@ class Tree(Base):
onupdate=lambda: datetime.now(timezone.utc)
)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this tree belongs to (organizational context)"
)
# Fork tracking
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
@@ -192,9 +183,7 @@ class Tree(Base):
cascade="all, delete-orphan"
)
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="trees")
# New organization relationships
# Organization relationships
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
tags: Mapped[list["TreeTag"]] = relationship(
"TreeTag",

View File

@@ -0,0 +1,37 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class UserPinnedTree(Base):
"""Tracks which trees a user has pinned to their sidebar."""
__tablename__ = "user_pinned_trees"
__table_args__ = (
UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
pinned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)

View File

@@ -1,57 +0,0 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
from app.models.tree import Tree
from app.models.category import TreeCategory
class Workspace(Base):
"""Workspaces are the top-level organizational context for trees/flows.
They sit above the folder system — a workspace scopes which trees/flows
are visible, while folders remain for personal organization within.
"""
__tablename__ = "workspaces"
__table_args__ = (
UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
accent_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="workspaces")
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="workspace")
categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="workspace")

View File

@@ -37,7 +37,6 @@ class CategoryResponse(CategoryBase):
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
tree_count: int = 0 # Computed field
@@ -55,7 +54,6 @@ class CategoryListResponse(BaseModel):
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
tree_count: int = 0
class Config:

View File

@@ -245,3 +245,37 @@ class TreeValidationResponse(BaseModel):
"""Response for tree validation endpoint."""
can_publish: bool
errors: list[ValidationError] = []
# --- Pinned Flows Schemas ---
class PinnedFlowResponse(BaseModel):
"""A pinned flow in the sidebar."""
id: UUID
tree_id: UUID
tree_name: str
tree_type: str
category_emoji: Optional[str] = None
category_name: Optional[str] = None
pinned_at: datetime
display_order: int
class Config:
from_attributes = True
class PinnedFlowsListResponse(BaseModel):
"""List of pinned flows."""
items: list[PinnedFlowResponse]
count: int
class PinnedFlowReorderItem(BaseModel):
"""Single item in a reorder request."""
tree_id: UUID
display_order: int
class PinnedFlowReorderRequest(BaseModel):
"""Request to reorder pinned flows."""
order: list[PinnedFlowReorderItem]

View File

@@ -1,39 +0,0 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class WorkspaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: int = 0
class WorkspaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: Optional[int] = None
class WorkspaceResponse(BaseModel):
id: uuid.UUID
name: str
slug: str
description: Optional[str] = None
icon: Optional[str] = None
accent_color: Optional[str] = None
account_id: uuid.UUID
is_default: bool
sort_order: int
tree_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

File diff suppressed because it is too large Load Diff

View File

@@ -11,4 +11,4 @@ export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'
export { default as workspacesApi } from './workspaces'
export { default as pinnedFlowsApi } from './pinnedFlows'

View File

@@ -0,0 +1,40 @@
import { apiClient } from './client'
export interface PinnedFlow {
id: string
tree_id: string
tree_name: string
tree_type: 'troubleshooting' | 'procedural'
category_emoji?: string
category_name?: string
pinned_at: string
display_order: number
}
export interface PinnedFlowsResponse {
items: PinnedFlow[]
count: number
}
export const pinnedFlowsApi = {
list: async (): Promise<PinnedFlowsResponse> => {
const { data } = await apiClient.get('/trees/pinned')
return data
},
pin: async (treeId: string): Promise<PinnedFlow> => {
const { data } = await apiClient.post(`/trees/${treeId}/pin`)
return data
},
unpin: async (treeId: string): Promise<void> => {
await apiClient.delete(`/trees/${treeId}/pin`)
},
reorder: async (order: { tree_id: string; display_order: number }[]): Promise<PinnedFlowsResponse> => {
const { data } = await apiClient.patch('/trees/pinned/reorder', { order })
return data
},
}
export default pinnedFlowsApi

View File

@@ -1,25 +0,0 @@
import { apiClient } from './client'
import type { Workspace, WorkspaceCreate, WorkspaceUpdate } from '@/types'
export const workspacesApi = {
list: async (): Promise<Workspace[]> => {
const { data } = await apiClient.get('/workspaces')
return data
},
create: async (payload: WorkspaceCreate): Promise<Workspace> => {
const { data } = await apiClient.post('/workspaces', payload)
return data
},
update: async (id: string, payload: WorkspaceUpdate): Promise<Workspace> => {
const { data } = await apiClient.patch(`/workspaces/${id}`, payload)
return data
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/workspaces/${id}`)
},
}
export default workspacesApi

View File

@@ -3,7 +3,7 @@ import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom'
import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar'
@@ -14,15 +14,9 @@ export function AppLayout() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { effectiveRole } = usePermissions()
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// Fetch workspaces on mount
useEffect(() => {
fetchWorkspaces()
}, [fetchWorkspaces])
// Close mobile menu on route change
const [prevPath, setPrevPath] = useState(location.pathname)
if (prevPath !== location.pathname) {

View File

@@ -2,6 +2,13 @@ import { Link, useLocation } from 'react-router-dom'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavSubItem {
href: string
label: string
count?: number
isActive?: boolean
}
interface NavItemProps {
href: string
icon: LucideIcon
@@ -9,16 +16,22 @@ interface NavItemProps {
badge?: number | 'dot'
matchPaths?: string[]
collapsed?: boolean
children?: NavSubItem[]
}
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed }: NavItemProps) {
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, children }: NavItemProps) {
const location = useLocation()
const fullPath = location.pathname + location.search
const isActive = matchPaths
? matchPaths.some(p => location.pathname.startsWith(p))
: href === '/'
? location.pathname === '/'
: location.pathname.startsWith(href)
// Check if any child is specifically active
const activeChild = children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
const isParentDimmed = !!activeChild && isActive
if (collapsed) {
return (
<Link
@@ -45,33 +58,65 @@ export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed
}
return (
<Link
to={href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
isActive
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
)}
>
{/* Active indicator bar */}
{isActive && (
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
)}
<div>
<Link
to={href}
className={cn(
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
isActive
? isParentDimmed
? 'bg-[hsl(var(--sidebar-active))]/50 text-foreground/70'
: 'bg-[hsl(var(--sidebar-active))] text-foreground'
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
)}
>
{/* Active indicator bar */}
{isActive && !isParentDimmed && (
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
)}
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
<span className="truncate">{label}</span>
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
<span className="truncate">{label}</span>
{/* Badge */}
{badge !== undefined && badge !== 0 && (
badge === 'dot' ? (
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
) : (
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
{badge}
</span>
)
{/* Badge */}
{badge !== undefined && badge !== 0 && (
badge === 'dot' ? (
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
) : (
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
{badge}
</span>
)
)}
</Link>
{/* Sub-items */}
{children && children.length > 0 && (
<div className="mt-0.5 space-y-0.5">
{children.map(child => {
const childActive = fullPath === child.href || fullPath.startsWith(child.href + '&')
return (
<Link
key={child.href}
to={child.href}
className={cn(
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
childActive
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
)}
>
<span className="truncate">{child.label}</span>
{child.count !== undefined && (
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
{child.count}
</span>
)}
</Link>
)
})}
</div>
)}
</Link>
</div>
)
}

View File

@@ -22,7 +22,7 @@ interface QuickAction {
const ACTIONS: QuickAction[] = [
{ id: 'new-tree', icon: Plus, label: 'New Troubleshooting Flow', description: 'Create a branching decision tree', path: '/trees/new', color: '#3b82f6' },
{ id: 'new-procedure', icon: Plus, label: 'New Procedure', description: 'Create a step-by-step procedure', path: '/flows/new', color: '#8b5cf6' },
{ id: 'new-project', icon: Plus, label: 'New Project', description: 'Create a step-by-step project', path: '/flows/new', color: '#8b5cf6' },
{ id: 'sessions', icon: Play, label: 'View Sessions', description: 'See active and recent sessions', path: '/sessions', color: '#f59e0b' },
{ id: 'step-library', icon: Bookmark, label: 'Step Library', description: 'Browse reusable steps', path: '/step-library', color: '#10b981' },
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#6366f1' },
@@ -130,7 +130,7 @@ export function QuickLaunch({ open, onClose }: QuickLaunchProps) {
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tree.name}</p>
<p className="text-[0.6875rem] text-muted-foreground">
{tree.tree_type === 'procedural' ? 'Procedure' : 'Troubleshooting'} · {tree.usage_count} uses
{tree.tree_type === 'procedural' ? 'Project' : 'Troubleshooting'} · {tree.usage_count} uses
</p>
</div>
<Play size={14} className="ml-auto shrink-0 opacity-40" />

View File

@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
import { WorkspaceSwitcher } from '@/components/workspace/WorkspaceSwitcher'
import { CategoryList } from '@/components/workspace/CategoryList'
import { TagCloud } from '@/components/workspace/TagCloud'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { CategoryList } from '@/components/sidebar/CategoryList'
import { TagCloud } from '@/components/sidebar/TagCloud'
import { PinnedFlowsSection } from '@/components/sidebar/PinnedFlowsSection'
import { NavItem } from './NavItem'
import { categoriesApi, tagsApi, sessionsApi } from '@/api'
import { categoriesApi, tagsApi, sessionsApi, treesApi } from '@/api'
import { pinnedFlowsApi } from '@/api/pinnedFlows'
import type { PinnedFlow } from '@/api/pinnedFlows'
import { toast } from '@/lib/toast'
interface CategoryItem {
id: string
@@ -17,26 +19,27 @@ interface CategoryItem {
}
export function Sidebar() {
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
const activeWorkspaceId = useWorkspaceStore(s => s.activeWorkspaceId)
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const toggleSidebar = useWorkspaceStore(s => s.toggleSidebar)
const labels = getWorkspaceLabels(activeWorkspace?.slug)
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const toggleSidebar = useUserPreferencesStore(s => s.toggleSidebar)
const [categories, setCategories] = useState<CategoryItem[]>([])
const [tags, setTags] = useState<string[]>([])
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null)
const [activeTags, setActiveTags] = useState<string[]>([])
const [activeSessionCount, setActiveSessionCount] = useState(0)
const [pinnedFlows, setPinnedFlows] = useState<PinnedFlow[]>([])
const [treeCounts, setTreeCounts] = useState({ total: 0, troubleshooting: 0, procedural: 0 })
// Fetch categories, tags, and active session count when workspace changes
// Fetch sidebar data on mount
useEffect(() => {
const fetchData = async () => {
try {
const [cats, tagList, activeSessions] = await Promise.all([
const [cats, tagList, activeSessions, allTrees, pinnedData] = await Promise.all([
categoriesApi.list(),
tagsApi.list().catch(() => []),
sessionsApi.list({ completed: false, size: 50 }).catch(() => []),
treesApi.list({ sort_by: 'name' }).catch(() => []),
pinnedFlowsApi.list().catch(() => ({ items: [], count: 0 })),
])
setCategories(cats.map(c => ({
id: c.id,
@@ -46,12 +49,18 @@ export function Sidebar() {
})))
setTags(tagList.map((t: { name: string }) => t.name).slice(0, 15))
setActiveSessionCount(activeSessions.length)
setPinnedFlows(pinnedData.items)
const total = allTrees.length
const troubleshooting = allTrees.filter(t => t.tree_type === 'troubleshooting').length
const procedural = allTrees.filter(t => t.tree_type === 'procedural').length
setTreeCounts({ total, troubleshooting, procedural })
} catch {
// Silently handle errors
}
}
fetchData()
}, [activeWorkspaceId])
}, [])
const navigate = useNavigate()
const location = useLocation()
@@ -91,6 +100,16 @@ export function Sidebar() {
navigate(`/trees?${params.toString()}`)
}
const handleUnpin = async (treeId: string) => {
try {
await pinnedFlowsApi.unpin(treeId)
setPinnedFlows(prev => prev.filter(f => f.tree_id !== treeId))
toast.success('Unpinned from sidebar')
} catch {
toast.error('Failed to unpin flow')
}
}
return (
<nav className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]">
{sidebarCollapsed ? (
@@ -107,16 +126,26 @@ export function Sidebar() {
</>
) : (
<>
{/* Workspace Switcher */}
<WorkspaceSwitcher />
{/* Pinned Flows */}
<PinnedFlowsSection flows={pinnedFlows} onUnpin={handleUnpin} />
<div className="border-b border-[hsl(var(--border-subtle))]" />
{/* Primary Navigation */}
<div className="px-3 py-2 space-y-0.5">
<NavItem href="/" icon={LayoutGrid} label="Dashboard" />
<NavItem href="/trees" icon={Box} label={labels.allItems} matchPaths={['/trees', '/flows']} />
<NavItem href="/my-trees" icon={PenLine} label={labels.editor} />
<NavItem
href="/trees"
icon={Box}
label="All Flows"
badge={treeCounts.total || undefined}
matchPaths={['/trees', '/flows']}
children={[
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: treeCounts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: treeCounts.procedural || undefined },
]}
/>
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />

View File

@@ -3,8 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { Search, Zap, LogOut, User, Shield, Settings } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { CommandPalette } from './CommandPalette'
import { QuickLaunch } from './QuickLaunch'
@@ -15,9 +14,7 @@ export function TopBar() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const { effectiveRole, isSuperAdmin } = usePermissions()
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
const sidebarCollapsed = useWorkspaceStore(s => s.sidebarCollapsed)
const labels = getWorkspaceLabels(activeWorkspace?.slug)
const sidebarCollapsed = useUserPreferencesStore(s => s.sidebarCollapsed)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
@@ -77,22 +74,25 @@ export function TopBar() {
)}
</Link>
{/* Spacer - push search to center */}
<div className="flex-1" />
{/* Search trigger */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="relative flex-1 text-left"
className="relative w-full text-left"
style={{ maxWidth: '480px' }}
>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<div className="w-full rounded-lg border border-border bg-card py-2 pl-9 pr-14 text-[0.8125rem] text-muted-foreground cursor-pointer hover:border-primary/30 transition-colors">
{labels.searchPlaceholder}
Search flows, sessions, tags
</div>
<span className="absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-label text-[0.625rem] text-muted-foreground">
K
{navigator.platform?.toLowerCase().includes('mac') ? '⌘K' : 'Ctrl+K'}
</span>
</button>
{/* Spacer */}
{/* Spacer - push actions to right */}
<div className="flex-1" />
{/* Action buttons */}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
import type { PinnedFlow } from '@/api/pinnedFlows'
interface PinnedFlowsSectionProps {
flows: PinnedFlow[]
onUnpin: (treeId: string) => void
}
export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps) {
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
return (
<div className="px-3 py-2">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex w-full items-center gap-1 px-3 mb-1 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground hover:text-foreground transition-colors"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
Pinned
</button>
{!collapsed && (
<div className="space-y-0.5 max-h-[280px] overflow-y-auto">
{flows.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
Pin your most-used flows here
</p>
) : (
flows.map(flow => (
<button
key={flow.tree_id}
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
onContextMenu={(e) => {
e.preventDefault()
onUnpin(flow.tree_id)
}}
className={cn(
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
)}
title={`${flow.tree_name} (right-click to unpin)`}
>
<span className="text-sm shrink-0">
{flow.tree_type === 'procedural' ? '📋' : '🔧'}
</span>
<span className="truncate flex-1 text-left">{flow.tree_name}</span>
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />
</button>
))
)}
</div>
)}
</div>
)
}

View File

@@ -1,136 +0,0 @@
import { useState } from 'react'
import { X, Loader2 } from 'lucide-react'
import { workspacesApi } from '@/api/workspaces'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface WorkspaceCreateModalProps {
open: boolean
onClose: () => void
}
const ICONS = ['📁', '🔧', '📋', '🚀', '🎯', '💼', '🔒', '📊', '🧪', '⚙️']
export function WorkspaceCreateModal({ open, onClose }: WorkspaceCreateModalProps) {
const fetchWorkspaces = useWorkspaceStore(s => s.fetchWorkspaces)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [icon, setIcon] = useState('📁')
const [saving, setSaving] = useState(false)
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.slice(0, 50)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !slug) return
setSaving(true)
try {
await workspacesApi.create({ name: name.trim(), slug, description: description.trim() || undefined, icon })
await fetchWorkspaces()
toast.success(`Workspace "${name}" created`)
setName('')
setDescription('')
setIcon('📁')
onClose()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to create workspace'
toast.error(message)
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div className="relative w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-2xl animate-scale-in">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-heading font-bold text-foreground">Create Workspace</h2>
<button onClick={onClose} className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Icon picker */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Icon</label>
<div className="flex flex-wrap gap-1.5">
{ICONS.map(i => (
<button
key={i}
type="button"
onClick={() => setIcon(i)}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg text-lg transition-colors',
icon === i
? 'bg-primary/10 border border-primary/30'
: 'border border-border hover:bg-accent'
)}
>
{i}
</button>
))}
</div>
</div>
{/* Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g., Network Operations"
className="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"
autoFocus
/>
{slug && (
<p className="mt-1 text-[0.6875rem] text-muted-foreground">
Slug: <code className="rounded bg-secondary px-1">{slug}</code>
</p>
)}
</div>
{/* Description */}
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Description <span className="text-muted-foreground font-normal">(optional)</span></label>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Brief description"
className="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"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving && <Loader2 size={14} className="animate-spin" />}
Create
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -1,87 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown, Plus } from 'lucide-react'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { WorkspaceCreateModal } from './WorkspaceCreateModal'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
export function WorkspaceSwitcher() {
const { workspaces, activeWorkspaceId, setActiveWorkspace } = useWorkspaceStore()
const activeWorkspace = workspaces.find(w => w.id === activeWorkspaceId)
const [open, setOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const handleSwitch = (ws: typeof workspaces[0]) => {
if (ws.id !== activeWorkspaceId) {
setActiveWorkspace(ws.id)
toast.success(`Switched to ${ws.name}`)
}
setOpen(false)
}
if (!activeWorkspace) return null
return (
<>
<div className="relative px-3 py-2" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-[hsl(var(--sidebar-hover))] transition-colors"
>
<span className="text-lg leading-none">{activeWorkspace.icon || '📁'}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-heading font-semibold text-foreground truncate">{activeWorkspace.name}</p>
{activeWorkspace.description && (
<p className="text-[0.6875rem] text-muted-foreground truncate">{activeWorkspace.description}</p>
)}
</div>
<ChevronDown size={14} className={cn('shrink-0 text-muted-foreground transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div className="absolute left-3 right-3 z-50 mt-1 rounded-lg border border-border bg-card shadow-xl animate-scale-in">
<div className="p-1">
{workspaces.map(ws => (
<button
key={ws.id}
onClick={() => handleSwitch(ws)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors',
ws.id === activeWorkspaceId
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<span className="text-base leading-none">{ws.icon || '📁'}</span>
<span className="flex-1 truncate text-sm">{ws.name}</span>
<span className="font-label text-[0.6875rem] text-muted-foreground">{ws.tree_count}</span>
</button>
))}
</div>
<div className="border-t border-border p-1">
<button
onClick={() => { setOpen(false); setCreateModalOpen(true) }}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Plus size={14} />
Add workspace
</button>
</div>
</div>
)}
</div>
<WorkspaceCreateModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
</>
)
}

View File

@@ -0,0 +1,38 @@
export interface FlowTypeLabels {
navLabel: string
singular: string
plural: string
newButton: string
searchPlaceholder: string
}
export const FLOW_TYPE_LABELS: Record<string, FlowTypeLabels> = {
all: {
navLabel: 'All Flows',
singular: 'Flow',
plural: 'Flows',
newButton: '+ Create Flow',
searchPlaceholder: 'Search flows, sessions, tags\u2026',
},
troubleshooting: {
navLabel: 'Troubleshooting',
singular: 'Flow',
plural: 'Flows',
newButton: '+ New Troubleshooting Flow',
searchPlaceholder: 'Search troubleshooting flows\u2026',
},
procedural: {
navLabel: 'Projects',
singular: 'Project',
plural: 'Projects',
newButton: '+ New Project',
searchPlaceholder: 'Search projects, runbooks\u2026',
},
}
export function getFlowLabels(typeFilter?: string): FlowTypeLabels {
if (typeFilter && typeFilter in FLOW_TYPE_LABELS) {
return FLOW_TYPE_LABELS[typeFilter]
}
return FLOW_TYPE_LABELS.all
}

View File

@@ -1,45 +0,0 @@
export interface WorkspaceLabels {
allItems: string
editor: string
newItem: string
searchPlaceholder: string
}
export const WORKSPACE_LABELS: Record<string, WorkspaceLabels> = {
troubleshooting: {
allItems: 'All Trees',
editor: 'Tree Editor',
newItem: 'New Tree',
searchPlaceholder: 'Search trees, sessions, tags\u2026',
},
procedures: {
allItems: 'All Procedures',
editor: 'Flow Editor',
newItem: 'New Procedure',
searchPlaceholder: 'Search procedures, runbooks\u2026',
},
policies: {
allItems: 'All Policies',
editor: 'Policy Editor',
newItem: 'New Policy',
searchPlaceholder: 'Search policies, compliance\u2026',
},
finance: {
allItems: 'All Finance Flows',
editor: 'Flow Editor',
newItem: 'New Flow',
searchPlaceholder: 'Search billing, procurement\u2026',
},
}
export const DEFAULT_LABELS: WorkspaceLabels = {
allItems: 'All Flows',
editor: 'Flow Editor',
newItem: 'New Flow',
searchPlaceholder: 'Search flows, sessions, tags\u2026',
}
export function getWorkspaceLabels(slug?: string): WorkspaceLabels {
if (slug && slug in WORKSPACE_LABELS) return WORKSPACE_LABELS[slug]
return DEFAULT_LABELS
}

View File

@@ -6,8 +6,6 @@ import { sessionsApi } from '@/api/sessions'
import type { TreeListItem } from '@/types'
import type { Session } from '@/types/session'
import { getTreeNavigatePath } from '@/lib/routing'
import { useWorkspaceStore } from '@/store/workspaceStore'
import { getWorkspaceLabels } from '@/constants/workspaceLabels'
import { usePermissions } from '@/hooks/usePermissions'
import { QuickStats } from '@/components/dashboard/QuickStats'
import { FiltersBar } from '@/components/dashboard/FiltersBar'
@@ -32,8 +30,6 @@ function timeAgo(dateStr: string): string {
export function QuickStartPage() {
const navigate = useNavigate()
const { canCreateTrees } = usePermissions()
const activeWorkspace = useWorkspaceStore(s => s.getActiveWorkspace())
const labels = getWorkspaceLabels(activeWorkspace?.slug)
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
@@ -147,17 +143,17 @@ export function QuickStartPage() {
Dashboard
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Welcome back. Here&apos;s what&apos;s happening in your workspace.
Welcome back. Here&apos;s what&apos;s happening with your flows.
</p>
</div>
<div className="flex items-center gap-2">
{canCreateTrees && (
<Link
to={activeWorkspace?.slug === 'procedures' ? '/flows/new' : '/trees/new'}
to="/trees/new"
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Plus size={16} />
{labels.newItem}
Create Flow
</Link>
)}
</div>
@@ -184,7 +180,7 @@ export function QuickStartPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query.length >= 2 && setShowResults(true)}
placeholder={labels.searchPlaceholder}
placeholder="Search flows, sessions, tags…"
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
{showResults && (
@@ -226,7 +222,7 @@ export function QuickStartPage() {
</div>
) : (
<SectionGroup
title={labels.allItems}
title="All Flows"
count={filteredTrees.length}
delay={200}
>

View File

@@ -1,12 +1,11 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
import { Plus, X, FolderOpen, RotateCcw, Play } from 'lucide-react'
import { Plus, X, RotateCcw, Play } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
import { FolderSidebar } from '@/components/library/FolderSidebar'
import { FolderEditModal } from '@/components/library/FolderEditModal'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { TreeGridView } from '@/components/library/TreeGridView'
@@ -64,9 +63,6 @@ export function TreeLibraryPage() {
const [editingFolder, setEditingFolder] = useState<FolderListItem | null>(null)
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null)
// Mobile folder sidebar state
const [mobileFolderOpen, setMobileFolderOpen] = useState(false)
// Delete confirmation state
const [treeToDelete, setTreeToDelete] = useState<TreeListItem | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -213,12 +209,6 @@ export function TreeLibraryPage() {
setFolderModalOpen(true)
}
const handleEditFolder = (folder: FolderListItem) => {
setEditingFolder(folder)
setNewFolderParentId(null)
setFolderModalOpen(true)
}
const handleDeleteTree = async () => {
if (!treeToDelete) return
setIsDeleting(true)
@@ -257,33 +247,20 @@ export function TreeLibraryPage() {
return (
<div className="flex h-full">
{/* Folder Sidebar */}
<FolderSidebar
selectedFolderId={selectedFolderId}
onFolderSelect={(id) => {
setSelectedFolderId(id)
setMobileFolderOpen(false)
}}
onCreateFolder={handleCreateFolder}
onEditFolder={handleEditFolder}
mobileOpen={mobileFolderOpen}
onMobileClose={() => setMobileFolderOpen(false)}
/>
{/* Main Content */}
<div className="flex-1 overflow-auto">
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-white sm:text-3xl">
{typeFilter === 'procedural' ? 'Procedures' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
</h1>
<p className="mt-2 text-white/40">
{typeFilter === 'procedural'
? 'Step-by-step procedures for project work'
? 'Step-by-step projects and runbooks'
: typeFilter === 'troubleshooting'
? 'Branching decision flows for troubleshooting'
: 'Browse and start troubleshooting flows and procedures'}
: 'Browse and start troubleshooting flows and projects'}
</p>
</div>
{canCreateTrees && (
@@ -295,7 +272,7 @@ export function TreeLibraryPage() {
)}
>
<Plus className="h-4 w-4" />
{typeFilter === 'procedural' ? 'Create Procedure' : 'Create Flow'}
{typeFilter === 'procedural' ? 'New Project' : 'Create Flow'}
</Link>
)}
</div>
@@ -303,18 +280,6 @@ export function TreeLibraryPage() {
{/* Search and Filter */}
<div className="mb-4 space-y-4">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Mobile folder button */}
<button
onClick={() => setMobileFolderOpen(true)}
className={cn(
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium md:hidden',
'text-white/40 hover:bg-white/10 hover:text-white',
selectedFolderId && 'border-white/30 text-white'
)}
>
<FolderOpen className="h-4 w-4" />
Folders
</button>
<div className="flex flex-1 gap-2">
<input
type="text"
@@ -372,7 +337,7 @@ export function TreeLibraryPage() {
: 'text-white/40 hover:text-white/60'
)}
>
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Procedures'}
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Projects'}
</button>
))}
</div>

View File

@@ -15,11 +15,13 @@ interface UserPreferencesState {
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
preferredEditorMode: EditorMode
setPreferredEditorMode: (mode: EditorMode) => void
sidebarCollapsed: boolean
toggleSidebar: () => void
}
export const useUserPreferencesStore = create<UserPreferencesState>()(
persist(
(set) => ({
(set, get) => ({
defaultExportFormat: 'markdown',
setDefaultExportFormat: (format) => set({ defaultExportFormat: format }),
treeLibraryView: 'grid',
@@ -28,6 +30,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
preferredEditorMode: 'form',
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
sidebarCollapsed: false,
toggleSidebar: () => set({ sidebarCollapsed: !get().sidebarCollapsed }),
}),
{
name: 'user-preferences-storage',

View File

@@ -1,59 +0,0 @@
import { create } from 'zustand'
import type { Workspace } from '@/types'
import { workspacesApi } from '@/api/workspaces'
interface WorkspaceState {
workspaces: Workspace[]
activeWorkspaceId: string | null
loading: boolean
sidebarCollapsed: boolean
setActiveWorkspace: (id: string) => void
fetchWorkspaces: () => Promise<void>
getActiveWorkspace: () => Workspace | undefined
toggleSidebar: () => void
}
export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
workspaces: [],
activeWorkspaceId: localStorage.getItem('active-workspace-id'),
loading: false,
sidebarCollapsed: localStorage.getItem('sidebar-collapsed') === 'true',
setActiveWorkspace: (id: string) => {
localStorage.setItem('active-workspace-id', id)
set({ activeWorkspaceId: id })
},
fetchWorkspaces: async () => {
set({ loading: true })
try {
const workspaces = await workspacesApi.list()
const state = get()
let activeId = state.activeWorkspaceId
// If no active workspace or active workspace doesn't exist, use default
if (!activeId || !workspaces.find(w => w.id === activeId)) {
const defaultWs = workspaces.find(w => w.is_default) || workspaces[0]
if (defaultWs) {
activeId = defaultWs.id
localStorage.setItem('active-workspace-id', activeId)
}
}
set({ workspaces, activeWorkspaceId: activeId, loading: false })
} catch {
set({ loading: false })
}
},
getActiveWorkspace: () => {
const { workspaces, activeWorkspaceId } = get()
return workspaces.find(w => w.id === activeWorkspaceId)
},
toggleSidebar: () => {
const next = !get().sidebarCollapsed
localStorage.setItem('sidebar-collapsed', String(next))
set({ sidebarCollapsed: next })
},
}))

View File

@@ -8,7 +8,6 @@ export * from './category'
export * from './folder'
export * from './step'
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
export type { Workspace, WorkspaceCreate, WorkspaceUpdate } from './workspace'
export * from './admin'
// API response wrapper types

View File

@@ -1,32 +0,0 @@
export interface Workspace {
id: string
name: string
slug: string
description: string | null
icon: string | null
accent_color: string | null
account_id: string
is_default: boolean
sort_order: number
tree_count: number
created_at: string
updated_at: string
}
export interface WorkspaceCreate {
name: string
slug: string
description?: string
icon?: string
accent_color?: string
sort_order?: number
}
export interface WorkspaceUpdate {
name?: string
slug?: string
description?: string
icon?: string
accent_color?: string
sort_order?: number
}