feat: UI design system - sidebar layout, workspace system, and shell redesign (#77)

* feat: add workspace system and sidebar layout (UI design system Phase A+B)

Backend: Workspace model, migration (036), schemas, CRUD API endpoints.
Adds workspace_id to trees and categories, seeds 4 default workspaces
per account, auto-assigns existing trees by tree_type.

Frontend: Complete AppLayout rewrite from top-nav to CSS Grid shell
with persistent sidebar + topbar. New components: WorkspaceSwitcher,
NavItem, CategoryList, TagCloud, TopBar, Sidebar. Dashboard components:
QuickStats, FiltersBar, SectionGroup, TreeListItem, SessionsPanel.
WorkspaceStore with localStorage persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add command palette search, dashboard rewrite, and shell height fixes (Phase C)

- Add ⌘K command palette with debounced search across flows and sessions
- Rewrite QuickStartPage as dashboard with stats, filters, sessions panel
- Fix h-[calc(100vh-4rem)] → h-full across all pages for CSS Grid shell
- Add active session count badge to sidebar Sessions nav item

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add sidebar collapse, category/tag filtering, and workspace CRUD (Phase D)

- Sidebar collapse/expand toggle with icon-only rail mode (persisted)
- Sidebar category/tag clicks navigate to /trees with URL params
- TreeLibraryPage syncs filters from URL search params bidirectionally
- Workspace create modal with icon picker and auto-slug generation
- TopBar logo adapts to collapsed sidebar state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Quick Launch modal with actions and recent flows

- Zap button opens Quick Launch with create/navigate shortcuts
- Shows recent flows for quick session start
- Keyboard navigation support (arrows + enter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add activity notifications panel with session feed

- Bell icon shows dot indicator for recent activity
- Dropdown panel shows recent sessions with status icons
- Links to session detail and sessions list page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 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>

* fix: collapsed sidebar layout scaling and toggle button size

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate auth pages to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate TreeLibraryPage to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate session pages to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate TreeEditorPage to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate TreeNavigationPage to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate session sharing components to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove workspace dropdown animation (dead code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate common components to new design system

Migrate 15 components from monochrome glass-card design to purple gradient
accent design system tokens (bg-card, border-border, text-foreground,
bg-gradient-brand, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate procedural and step library components to new design system

Migrate 10 components from monochrome glass-card design to purple gradient
accent design system tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate admin pages and components to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate remaining pages to new design system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: migrate remaining components to new design system

Migrates 38 files: tree-editor forms, session modals, step library,
common components, library views, tree preview, and misc UI to use
design tokens (bg-card, border-border, text-foreground, bg-accent,
bg-gradient-brand) replacing old monochrome patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: keep brand text visible on sidebar collapse, hide sub-items until hover

- TopBar: always show "ResolutionFlow" text regardless of sidebar state
- NavItem: sub-items (Troubleshooting, Projects) hidden by default,
  revealed on hover or when a child route is active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #77.
This commit is contained in:
chihlasm
2026-02-15 22:45:19 -05:00
committed by GitHub
parent ef829f06a4
commit fa709faa60
138 changed files with 5011 additions and 3796 deletions

View File

@@ -0,0 +1,102 @@
"""Add workspaces table, workspace_id to trees and categories, color to categories
Revision ID: 036
Revises: 035
Create Date: 2026-02-15
Adds workspace system for organizational context above folders.
"""
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 = '036'
down_revision: Union[str, None] = '035'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create 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'])
# Add workspace_id to trees
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'])
# Add color and workspace_id to tree_categories
op.add_column('tree_categories', sa.Column('color', sa.String(7), nullable=True, server_default='#3b82f6'))
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'])
# Seed default workspaces for each existing account and assign trees
op.execute("""
-- Create default workspaces for each account
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Troubleshooting', 'troubleshooting', 'Break/fix decision trees', '🔧', '#ef4444', a.id, true, 0
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Procedures', 'procedures', 'Step-by-step operational flows', '📋', '#3b82f6', a.id, false, 1
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Policies', 'policies', 'Compliance & policy builders', '📜', '#8b5cf6', a.id, false, 2
FROM accounts a;
INSERT INTO workspaces (name, slug, description, icon, accent_color, account_id, is_default, sort_order)
SELECT 'Finance', 'finance', 'Billing & procurement flows', '💰', '#22c55e', a.id, false, 3
FROM accounts a;
-- Assign existing trees to appropriate workspace based on tree_type
UPDATE trees t
SET workspace_id = w.id
FROM workspaces w
WHERE w.account_id = t.account_id
AND w.slug = 'troubleshooting'
AND t.tree_type = 'troubleshooting';
UPDATE trees t
SET workspace_id = w.id
FROM workspaces w
WHERE w.account_id = t.account_id
AND w.slug = 'procedures'
AND t.tree_type = 'procedural';
""")
def downgrade() -> None:
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')
op.drop_column('tree_categories', 'color')
op.drop_index('ix_trees_workspace_id', 'trees')
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
op.drop_column('trees', 'workspace_id')
op.drop_index('ix_workspaces_account_id', 'workspaces')
op.drop_index('ix_workspaces_slug', 'workspaces')
op.drop_table('workspaces')

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

@@ -20,6 +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 .user_pinned_tree import UserPinnedTree
__all__ = [
"User",
@@ -51,4 +52,5 @@ __all__ = [
"PlanFeatureDefault",
"AccountFeatureOverride",
"PlatformSetting",
"UserPinnedTree",
]

View File

@@ -47,6 +47,10 @@ class TreeCategory(Base):
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
color: Mapped[Optional[str]] = mapped_column(
String(7), nullable=True, default='#3b82f6',
comment="Hex color for category dot indicator"
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),

View File

@@ -120,7 +120,6 @@ class Tree(Base):
onupdate=lambda: datetime.now(timezone.utc)
)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
# Fork tracking
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
@@ -184,7 +183,7 @@ class Tree(Base):
cascade="all, delete-orphan"
)
# 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

@@ -36,6 +36,7 @@ class CategoryResponse(CategoryBase):
account_id: Optional[UUID] = None
display_order: int
is_active: bool
color: Optional[str] = None
created_at: datetime
updated_at: datetime
tree_count: int = 0 # Computed field
@@ -52,6 +53,7 @@ class CategoryListResponse(BaseModel):
account_id: Optional[UUID] = None
display_order: int
is_active: bool
color: Optional[str] = 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]