feat: Step Library sync + service account for default tree ownership

* feat: maintenance flow UX redesign — batch status hub, context strip, detail page upgrades (#85)

- Add BatchStatusPage (/flows/:id/batches/:batchId): per-target Start/Resume/View cards, progress bar, 5s polling while in-progress, completion outcome summary
- Add BatchStatusCard: handles not-started/in-progress/complete states with step progress for in-progress targets
- Add ActiveBatchBanner: amber banner on detail page when a batch is running, links to BatchStatusPage
- Add MaintenanceContextStrip: amber strip in ProceduralNavigationPage for maintenance flows showing target name, batch progress (X/Y complete), and Back to Batch nav
- Update MaintenanceFlowDetailPage: active batch banner, clickable run history rows with mini progress dots and outcome summaries, Run button loading state, post-launch navigates to BatchStatusPage
- Update ProceduralNavigationPage: renders MaintenanceContextStrip between top bar and content when tree_type === 'maintenance'; fetches batch progress once on mount
- Add batch_id filter to GET /sessions backend endpoint and SessionListParams frontend type
- Add /flows/:id/batches/:batchId route to router

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

* feat: session detail page — completion action + outcome summary card

- In-progress sessions: amber banner with "Complete Session" button opens
  SessionOutcomeModal to set outcome/notes/next-steps and finalize
- Completed sessions: colored outcome summary card (icon + outcome label +
  duration + notes + next steps) replaces dense header metadata; "Copy for
  Ticket" promoted to primary action inside the card
- Export toolbar de-emphasized to secondary row of smaller controls below
  the summary card

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

* feat: add library-page action props to StepCard (edit/delete/save)

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

* feat: pass library-page action props through StepLibraryBrowser + refreshKey

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

* feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm

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

* feat: Step Library page — create, edit, delete, save-to-library

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

* feat: add RuntimeStep union type for procedural custom steps

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

* feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge

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

* feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps

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

* feat: custom step insertion in procedural flow sessions

Engineers can add custom steps inline during execution. Steps are
persisted to session.custom_steps and restored on resume.

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

* fix: suppress StepFeedback on custom steps, fix resume stepState seeding, functional updater for step index

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

* docs: add tree forking UI design doc

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

* docs: add tree fork UI implementation plan

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

* feat: add ForkInfo type and fork fields to Tree/TreeListItem

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

* fix: align ForkInfo type with backend schema, remove redundant fork fields

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

* fix: ForkInfo placement, required fork_info field, add JSDoc

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

* feat: add ForkModal component with name and reason fields

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

* fix: ForkModal accessibility and UX (escape, click-outside, labels, maxLength)

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

* feat: open ForkModal on fork action in TreeLibraryPage

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

* feat: add ForkModal to MyTreesPage

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

* feat: show Fork chip badge on forked tree cards

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

* docs: add flow-to-library step sync design doc

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

* docs: add flow-to-library sync implementation plan

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

* feat: add sync tracking columns to step_library

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

* feat: add sync columns and source_tree relationship to StepLibrary model

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

* feat: add group_label to StepContent, is_flow_synced/source_tree_name to StepLibraryResponse

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

* feat: include is_flow_synced and source_tree_name in step list/detail responses

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

* fix: add is_flow_synced and source_tree_name to step list response

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

* fix: add selectinload and sync fields to search and get_step endpoints

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

* feat: add step_sync module with extraction and upsert logic

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

* fix: safe NOT IN placeholders for asyncpg, add deactivate docstring

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

* feat: trigger step library sync on tree publish and deactivate on delete

- Call sync_steps_from_tree in update_tree whenever the tree is published
  (status transitions to 'published' or is already published and structure changes)
- Call deactivate_synced_steps_for_tree in delete_tree before db.commit()
  so the FK SET NULL does not nullify source_tree_id before the WHERE clause runs
- Fix ::jsonb cast syntax in step_sync.py (asyncpg rejects :: operator in text()
  queries; replaced with CAST(:content AS jsonb))
- Add UniqueConstraint('source_tree_id','source_node_id') to StepLibrary model
  so Base.metadata.create_all (used by tests) creates the constraint that the
  ON CONFLICT clause in sync_steps_from_tree depends on

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

* feat: add is_flow_synced and source_tree_name to Step types

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

* feat: show From Flow badge and lock icon on flow-synced StepCard

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

* feat: show source flow name in StepDetailModal for synced steps

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

* feat: add Library Visibility select to procedural StepEditor

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

* fix: address code review issues in flow-to-library sync

- Fix sync trigger: only fire on publish transition, not every PUT
- Add TestSyncOnPublish integration tests (2 tests, 16 total passing)
- Add group_label to frontend StepContent interface
- Guard Library Visibility select to procedure_step nodes only
- Block API edits to flow-synced steps (400 read-only guard)

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

* fix: handle None author_id in step sync to avoid invalid UUID error

When a system/default tree has no author (author_id is None),
str(None) produces the literal string 'None' which asyncpg
rejects as an invalid UUID for the created_by column.

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

* fix: add ResolutionFlow service account to own default tree steps in library

Default/system trees had no author_id (NULL), causing a NOT NULL violation
when syncing steps to step_library.created_by on publish.

- Add is_service_account flag to users table (migration 4f4137ce)
- Add service_account.py: idempotent ensure_service_account() creates
  noreply@resolutionflow.com with unusable password on startup
- Cache service account ID on app.state at lifespan startup
- Add get_service_account_id() FastAPI dep (returns None in tests)
- sync_steps_from_tree: resolve author_id or service_account_id as created_by
- create_tree: set author_id=service_account_id for is_default trees
- Migration 1490781700bc: backfill author_id on 31 existing default trees

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #89.
This commit is contained in:
chihlasm
2026-02-25 23:17:29 -05:00
committed by GitHub
parent a6abd23727
commit e6a0c0549b
45 changed files with 4261 additions and 270 deletions

View File

@@ -275,6 +275,10 @@ navigate(`/trees/${newTree.id}/edit`)
**25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`.
**26. `sessionsApi.list` supports `batch_id` filter (added Feb 2026):** Both backend `GET /sessions` and frontend `SessionListParams` accept `batch_id` for querying all sessions in a maintenance batch. Use `sessionsApi.list({ batch_id })` to fetch batch-scoped sessions.
**27. Maintenance batch sessions are created all-at-once at launch:** All sessions in a batch exist immediately after `batchLaunchApi.launch()` with `batch_id` + `target_label` set. `started_at` is null until a user begins executing that target — there is no "pending session creation" state.
---
## RBAC & Permissions

View File

@@ -0,0 +1,94 @@
"""backfill_default_tree_author_id_to_service_account
Revision ID: 1490781700bc
Revises: 4f4137ce79e5
Create Date: 2026-02-25 21:26:00.000000
Backfill author_id on is_default trees to the ResolutionFlow service account
(noreply@resolutionflow.com). The service account is created here if it does
not yet exist (idempotent), so this migration is safe to run before or after
the app starts.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
import uuid
# revision identifiers, used by Alembic.
revision: str = '1490781700bc'
down_revision: Union[str, None] = '4f4137ce79e5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
SERVICE_ACCOUNT_NAME = "ResolutionFlow"
def upgrade() -> None:
conn = op.get_bind()
# Ensure service account exists
row = conn.execute(
sa.text("SELECT id FROM users WHERE email = :email"),
{"email": SERVICE_ACCOUNT_EMAIL},
).fetchone()
if row is None:
service_id = str(uuid.uuid4())
conn.execute(
sa.text("""
INSERT INTO users (
id, email, name, password_hash, role,
is_super_admin, is_team_admin, is_active,
is_service_account, must_change_password,
account_role, created_at
) VALUES (
:id, :email, :name, :password_hash, 'engineer',
false, false, true,
true, false,
'engineer', NOW()
)
"""),
{
"id": service_id,
"email": SERVICE_ACCOUNT_EMAIL,
"name": SERVICE_ACCOUNT_NAME,
"password_hash": "!service-account-no-login",
},
)
else:
service_id = str(row[0])
# Backfill is_default trees that have no author
result = conn.execute(
sa.text("""
UPDATE trees
SET author_id = :service_id
WHERE author_id IS NULL AND is_default = true
"""),
{"service_id": service_id},
)
print(f"[backfill] Set author_id to service account on {result.rowcount} default trees")
def downgrade() -> None:
# Restore NULL on trees that were authored by the service account and are default
conn = op.get_bind()
row = conn.execute(
sa.text("SELECT id FROM users WHERE email = :email"),
{"email": SERVICE_ACCOUNT_EMAIL},
).fetchone()
if row is None:
return
service_id = str(row[0])
conn.execute(
sa.text("""
UPDATE trees
SET author_id = NULL
WHERE author_id = :service_id AND is_default = true
"""),
{"service_id": service_id},
)

View File

@@ -0,0 +1,34 @@
"""add_is_service_account_to_users
Revision ID: 4f4137ce79e5
Revises: fb1481317ff6
Create Date: 2026-02-25 20:28:46.075639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4f4137ce79e5'
down_revision: Union[str, None] = 'fb1481317ff6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'users',
sa.Column(
'is_service_account',
sa.Boolean(),
nullable=False,
server_default='false',
)
)
def downgrade() -> None:
op.drop_column('users', 'is_service_account')

View File

@@ -0,0 +1,47 @@
"""add_step_library_sync_fields
Revision ID: fb1481317ff6
Revises: a1b2c3d4e5f6
Create Date: 2026-02-25 03:19:52.600292
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fb1481317ff6'
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('step_library', sa.Column('source_tree_id', sa.UUID(), nullable=True))
op.add_column('step_library', sa.Column('source_node_id', sa.String(255), nullable=True))
op.add_column('step_library', sa.Column('is_flow_synced', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('step_library', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
'fk_step_library_source_tree',
'step_library', 'trees',
['source_tree_id'], ['id'],
ondelete='SET NULL'
)
op.create_unique_constraint(
'uq_step_library_source_node',
'step_library',
['source_tree_id', 'source_node_id']
)
op.create_index('ix_step_library_source_tree_id', 'step_library', ['source_tree_id'])
def downgrade() -> None:
op.drop_index('ix_step_library_source_tree_id', 'step_library')
op.drop_constraint('uq_step_library_source_node', 'step_library', type_='unique')
op.drop_constraint('fk_step_library_source_tree', 'step_library', type_='foreignkey')
op.drop_column('step_library', 'last_synced_at')
op.drop_column('step_library', 'is_flow_synced')
op.drop_column('step_library', 'source_node_id')
op.drop_column('step_library', 'source_tree_id')

View File

@@ -155,6 +155,14 @@ async def require_account_owner(
)
def get_service_account_id(request: Request) -> Optional[UUID]:
"""Return the cached ResolutionFlow service account UUID from app.state.
Returns None in test environments where lifespan startup did not run.
"""
return getattr(request.app.state, "service_account_id", None)
async def get_plan_limits_for_user(
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],

View File

@@ -38,6 +38,7 @@ async def list_sessions(
client_name: Optional[str] = Query(None, description="Search by client name (partial match)"),
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"),
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
@@ -73,6 +74,10 @@ async def list_sessions(
if tree_id:
query = query.where(Session.tree_id == tree_id)
# Batch ID filter
if batch_id:
query = query.where(Session.batch_id == batch_id)
# Date range filters
if started_after:
query = query.where(Session.started_at >= started_after)

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, desc, Integer, case
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.api.deps import get_current_active_user, require_engineer_or_admin
@@ -39,7 +40,7 @@ async def get_step_or_404(
select(StepLibrary).where(
StepLibrary.id == step_id,
StepLibrary.is_active == True
)
).options(selectinload(StepLibrary.source_tree))
)
step = result.scalar_one_or_none()
if not step:
@@ -72,7 +73,7 @@ async def list_steps(
query = select(StepLibrary).where(
StepLibrary.is_active == True,
build_step_visibility_filter(current_user)
)
).options(selectinload(StepLibrary.source_tree))
# Apply filters
if visibility:
@@ -117,6 +118,8 @@ async def list_steps(
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
"is_flow_synced": step.is_flow_synced,
"source_tree_name": step.source_tree.name if step.source_tree else None,
}
# Get category name if exists
@@ -154,7 +157,7 @@ async def search_steps(
StepLibrary.is_active == True,
build_step_visibility_filter(current_user),
func.to_tsvector('english', StepLibrary.title).match(search_query)
).order_by(desc(StepLibrary.rating_average)).limit(limit)
).options(selectinload(StepLibrary.source_tree)).order_by(desc(StepLibrary.rating_average)).limit(limit)
result = await db.execute(query)
steps = result.scalars().all()
@@ -174,6 +177,8 @@ async def search_steps(
"is_featured": step.is_featured,
"created_by": step.created_by,
"created_at": step.created_at,
"is_flow_synced": step.is_flow_synced,
"source_tree_name": step.source_tree.name if step.source_tree else None,
}
if step.category_id:
@@ -247,6 +252,8 @@ async def get_step(
"is_active": step.is_active,
"created_at": step.created_at,
"updated_at": step.updated_at,
"is_flow_synced": step.is_flow_synced,
"source_tree_name": step.source_tree.name if step.source_tree else None,
}
# Get category name if exists
@@ -346,6 +353,12 @@ async def update_step(
"""Update a step (owner or admin only)."""
step = await get_step_or_404(step_id, db, current_user, check_edit=True)
if step.is_flow_synced:
raise HTTPException(
status_code=400,
detail="Flow-synced steps are read-only. Fork to customize."
)
# Validate category if being updated
if step_data.category_id:
cat_result = await db.execute(

View File

@@ -21,13 +21,14 @@ from app.schemas.tree import (
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.api.deps import get_current_active_user, require_engineer_or_admin, require_admin, get_service_account_id
from app.core.permissions import can_edit_tree, can_access_tree
from app.core.filters import build_tree_access_filter
from app.core.subscriptions import check_tree_limit
from app.core.audit import log_audit
from app.core.config import settings
from app.core.tree_validation import can_publish_tree
from app.core.step_sync import sync_steps_from_tree, deactivate_synced_steps_for_tree
router = APIRouter(prefix="/trees", tags=["trees"])
@@ -399,7 +400,8 @@ async def get_tree(
async def create_tree(
tree_data: TreeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
current_user: Annotated[User, Depends(require_engineer_or_admin)],
service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)],
):
"""Create a new tree (engineers and admins only).
@@ -464,7 +466,7 @@ async def create_tree(
tree_type=tree_data.tree_type,
tree_structure=tree_data.tree_structure,
intake_form=intake_form_data,
author_id=None if is_default else current_user.id, # Default trees have no author
author_id=service_account_id if is_default else current_user.id,
account_id=None if is_default else current_user.account_id,
is_public=True if is_default else tree_data.is_public, # Default trees are always public
is_default=is_default,
@@ -548,7 +550,8 @@ async def update_tree(
tree_id: UUID,
tree_data: TreeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
current_user: Annotated[User, Depends(require_engineer_or_admin)],
service_account_id: Annotated[Optional[UUID], Depends(get_service_account_id)],
):
"""Update an existing tree (engineers and admins only).
@@ -640,6 +643,22 @@ async def update_tree(
if "tree_structure" in update_data:
tree.version += 1
# Sync steps to step library on publish transition only
if update_data.get("status") == 'published':
_structure = update_data.get("tree_structure", tree.tree_structure)
_type = update_data.get("tree_type", tree.tree_type)
_is_public = update_data.get("is_public", tree.is_public)
await sync_steps_from_tree(
db=db,
tree_id=tree.id,
tree_type=_type,
tree_structure=_structure,
author_id=tree.author_id,
account_id=tree.account_id,
is_public=_is_public,
service_account_id=service_account_id,
)
# Handle tags replacement
if tags_data is not None:
from app.models.tag import tree_tag_assignments
@@ -753,6 +772,10 @@ async def delete_tree(
tree_tag_assignments.delete().where(tree_tag_assignments.c.tree_id == tree.id)
)
# Deactivate any synced step library entries before deletion
# (must happen before db.delete/commit — FK SET NULL would lose the reference)
await deactivate_synced_steps_for_tree(db, tree.id)
await log_audit(db, current_user.id, "tree.delete", "tree", tree.id,
{"tree_name": tree.name})
await db.commit()

View File

@@ -0,0 +1,60 @@
"""ResolutionFlow system service account.
This module manages the platform-level service account used as the author
for system/default content (seeded trees, synced step library entries, etc.).
The service account ID is resolved once at startup and cached on app.state
so that sync operations can use it without a DB query per request.
"""
from __future__ import annotations
import uuid
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
SERVICE_ACCOUNT_NAME = "ResolutionFlow"
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
"""Ensure the ResolutionFlow service account exists and return its ID.
Idempotent — safe to call on every startup. Creates the account if it
does not exist. The account has no usable password and is_service_account=True
so it can never log in via normal auth flows.
"""
from app.models.user import User
result = await db.execute(
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
)
user = result.scalar_one_or_none()
if user is not None:
if not user.is_service_account:
user.is_service_account = True
await db.commit()
return user.id
# Create the service account with a random, unusable password hash
new_user = User(
id=uuid.uuid4(),
email=SERVICE_ACCOUNT_EMAIL,
name=SERVICE_ACCOUNT_NAME,
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
role="engineer",
is_super_admin=False,
is_team_admin=False,
is_active=True,
is_service_account=True,
must_change_password=False,
account_role="engineer",
)
db.add(new_user)
await db.commit()
logger.info(f"[service_account] Created service account (id={new_user.id})")
return new_user.id

View File

@@ -0,0 +1,222 @@
"""Sync steps from published flows into the step library."""
from __future__ import annotations
import json
from typing import Any, Generator, Literal, Optional
from uuid import UUID
from datetime import datetime, timezone
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
StepVisibility = Literal['private', 'team', 'public']
def resolve_step_visibility(
is_public: bool,
account_id: Optional[UUID],
node_override: Optional[str],
) -> StepVisibility:
"""Resolve the visibility for a synced step.
Priority: node-level library_visibility overrides flow visibility.
Flow visibility: 'public' if is_public, otherwise 'team'.
"""
if node_override in ('team', 'public'):
return node_override # type: ignore[return-value]
return 'public' if is_public else 'team'
def _normalize_commands(raw: Any) -> list[dict]:
"""Normalize the commands field to a list of StepCommand dicts."""
if not raw:
return []
if isinstance(raw, str):
return [{"label": "", "command": raw, "command_type": None}]
if isinstance(raw, list):
result = []
for item in raw:
if isinstance(item, str):
result.append({"label": "", "command": item, "command_type": None})
elif isinstance(item, dict):
result.append({
"label": item.get("label", ""),
"command": item.get("code", item.get("command", "")),
"command_type": item.get("language", item.get("command_type")),
})
return result
return []
def _walk_troubleshooting(node: dict) -> Generator[dict, None, None]:
"""Recursively yield action and solution nodes from a troubleshooting tree."""
if node.get("type") in ("action", "solution"):
yield node
for child in node.get("children", []):
yield from _walk_troubleshooting(child)
def extract_steps_for_sync(
tree_structure: dict,
tree_type: str,
) -> Generator[dict, None, None]:
"""Extract step dicts ready for upsert from a tree structure.
Yields dicts with keys:
source_node_id, title, step_type, content (dict), node_visibility_override
"""
if tree_type in ("procedural", "maintenance"):
steps = tree_structure.get("steps", [])
current_section: Optional[str] = None
for node in steps:
node_type = node.get("type")
if node_type == "section_header":
current_section = node.get("title") or node.get("section_header")
continue
if node_type != "procedure_step":
continue
instructions = node.get("description") or node.get("title", "")
commands = _normalize_commands(node.get("commands")) or None
content: dict = {"instructions": instructions}
if node.get("expected_outcome"):
content["help_text"] = node["expected_outcome"]
if commands:
content["commands"] = commands
if current_section:
content["group_label"] = current_section
yield {
"source_node_id": node["id"],
"title": node.get("title", "Untitled step"),
"step_type": "action",
"content": content,
"node_visibility_override": node.get("library_visibility"),
}
elif tree_type == "troubleshooting":
for node in _walk_troubleshooting(tree_structure):
instructions = node.get("description") or node.get("title", "")
yield {
"source_node_id": node["id"],
"title": node.get("title", "Untitled step"),
"step_type": "action" if node["type"] == "action" else "solution",
"content": {"instructions": instructions},
"node_visibility_override": None,
}
async def sync_steps_from_tree(
db: AsyncSession,
tree_id: UUID,
tree_type: str,
tree_structure: dict,
author_id: Optional[UUID],
account_id: Optional[UUID],
is_public: bool,
service_account_id: Optional[UUID] = None,
) -> int:
"""Upsert step library entries from a published tree.
Returns the number of steps synced.
For default/system trees that have no author_id, pass service_account_id
so that created_by is set to the ResolutionFlow service account.
"""
resolved_author_id = author_id or service_account_id
if not resolved_author_id:
return 0
now = datetime.now(timezone.utc)
extracted = list(extract_steps_for_sync(tree_structure, tree_type))
for step_data in extracted:
visibility = resolve_step_visibility(
is_public=is_public,
account_id=account_id,
node_override=step_data["node_visibility_override"],
)
await db.execute(
text("""
INSERT INTO step_library (
id, title, step_type, content, created_by, account_id,
visibility, is_flow_synced, source_tree_id, source_node_id,
last_synced_at, tags, is_active,
usage_count, rating_average, rating_count,
helpful_yes, helpful_no, is_featured, is_verified,
created_at, updated_at
) VALUES (
gen_random_uuid(), :title, :step_type, CAST(:content AS jsonb),
:created_by, :account_id, :visibility, true,
:source_tree_id, :source_node_id, :last_synced_at,
'{}', true,
0, 0, 0, 0, 0, false, false,
:now, :now
)
ON CONFLICT (source_tree_id, source_node_id)
DO UPDATE SET
title = EXCLUDED.title,
step_type = EXCLUDED.step_type,
content = EXCLUDED.content,
visibility = EXCLUDED.visibility,
last_synced_at = EXCLUDED.last_synced_at,
updated_at = EXCLUDED.updated_at,
is_active = true
"""),
{
"title": step_data["title"],
"step_type": step_data["step_type"],
"content": json.dumps(step_data["content"]),
"created_by": str(resolved_author_id),
"account_id": str(account_id) if account_id else None,
"visibility": visibility,
"source_tree_id": str(tree_id),
"source_node_id": step_data["source_node_id"],
"last_synced_at": now,
"now": now,
}
)
# Soft-delete previously synced steps that no longer exist in the tree
current_node_ids = [s["source_node_id"] for s in extracted]
if current_node_ids:
# Build NOT IN using explicit named placeholders — asyncpg does not
# auto-cast a Python list to a PostgreSQL array in text() queries.
placeholders = ", ".join(f":id_{i}" for i in range(len(current_node_ids)))
params = {f"id_{i}": nid for i, nid in enumerate(current_node_ids)}
params.update({"tree_id": str(tree_id), "now": now})
await db.execute(
text(f"""
UPDATE step_library
SET is_active = false, updated_at = :now
WHERE source_tree_id = :tree_id
AND is_flow_synced = true
AND source_node_id NOT IN ({placeholders})
"""),
params
)
else:
await db.execute(
text("""
UPDATE step_library
SET is_active = false, updated_at = :now
WHERE source_tree_id = :tree_id AND is_flow_synced = true
"""),
{"tree_id": str(tree_id), "now": now}
)
return len(extracted)
async def deactivate_synced_steps_for_tree(db: AsyncSession, tree_id: UUID) -> None:
"""Soft-delete all synced library entries for a tree (on tree delete/deactivate).
Must be called BEFORE deleting the tree row — after deletion the FK ondelete='SET NULL'
will null source_tree_id, making the WHERE clause match nothing.
"""
await db.execute(
text("""
UPDATE step_library
SET is_active = false, updated_at = :now
WHERE source_tree_id = :tree_id AND is_flow_synced = true
"""),
{"tree_id": str(tree_id), "now": datetime.now(timezone.utc)}
)

View File

@@ -14,6 +14,7 @@ from app.core.middleware import RequestLoggingMiddleware, ErrorLoggingMiddleware
from app.core.rate_limit import limiter
from app.api.router import api_router
from app.core.scheduler import scheduler, load_all_schedules, _cleanup_expired_ai_conversations
from app.core.service_account import ensure_service_account
# Initialize logging configuration
setup_logging()
@@ -103,6 +104,12 @@ async def lifespan(app: FastAPI):
# Note: In production, use Alembic migrations instead of init_db
# await init_db()
# Ensure service account exists and cache its ID for sync operations
async with async_session_maker() as db:
service_account_id = await ensure_service_account(db)
app.state.service_account_id = service_account_id
logger.info(f"[service_account] Service account ready (id={service_account_id})")
# Start maintenance schedule runner + AI conversation cleanup
scheduler.start()
async with async_session_maker() as db:

View File

@@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint
from sqlalchemy import String, DateTime, Integer, Boolean, Text, Numeric, ForeignKey, CheckConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from app.core.database import Base
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
from app.models.account import Account
from app.models.step_category import StepCategory
from app.models.session import Session
from app.models.tree import Tree
class StepLibrary(Base):
@@ -22,6 +23,7 @@ class StepLibrary(Base):
"step_type IN ('decision', 'action', 'solution')",
name='ck_step_library_step_type'
),
UniqueConstraint('source_tree_id', 'source_node_id', name='uq_step_library_source_node'),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -95,10 +97,26 @@ class StepLibrary(Base):
# Soft delete
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Sync tracking (flow-sourced steps)
source_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey('trees.id', ondelete='SET NULL'),
nullable=True,
index=True
)
source_node_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_flow_synced: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
team: Mapped[Optional["Team"]] = relationship("Team")
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="step_library")
source_tree: Mapped[Optional["Tree"]] = relationship(
"Tree",
foreign_keys=[source_tree_id],
lazy="select"
)
category: Mapped[Optional["StepCategory"]] = relationship("StepCategory")
ratings: Mapped[list["StepRating"]] = relationship("StepRating", back_populates="step", cascade="all, delete-orphan")
usage_logs: Mapped[list["StepUsageLog"]] = relationship("StepUsageLog", back_populates="step", cascade="all, delete-orphan")

View File

@@ -39,6 +39,7 @@ class User(Base):
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
is_service_account: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Account-based multi-tenancy (new)

View File

@@ -17,6 +17,7 @@ class StepContent(BaseModel):
instructions: str = Field(..., min_length=1)
help_text: Optional[str] = None
commands: Optional[list[StepCommand]] = None
group_label: Optional[str] = None # Section header this step belongs to (for flow-synced steps)
# Base schemas
@@ -59,6 +60,8 @@ class StepLibraryResponse(StepLibraryBase):
# Computed fields (populated by API)
category_name: Optional[str] = None
author_name: Optional[str] = None
is_flow_synced: bool = False
source_tree_name: Optional[str] = None
class Config:
from_attributes = True
@@ -79,6 +82,8 @@ class StepLibraryListResponse(BaseModel):
created_by: UUID
author_name: Optional[str] = None
created_at: datetime
is_flow_synced: bool = False
source_tree_name: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,216 @@
"""Tests for flow-to-library step sync."""
import pytest
from uuid import uuid4
from app.core.step_sync import extract_steps_for_sync, resolve_step_visibility
class TestResolveStepVisibility:
"""Test visibility resolution logic."""
def test_public_flow_gives_public_steps(self):
result = resolve_step_visibility(is_public=True, account_id=None, node_override=None)
assert result == 'public'
def test_team_flow_gives_team_steps(self):
result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override=None)
assert result == 'team'
def test_private_flow_gives_team_steps(self):
result = resolve_step_visibility(is_public=False, account_id=None, node_override=None)
assert result == 'team'
def test_node_override_takes_precedence(self):
result = resolve_step_visibility(is_public=True, account_id=None, node_override='team')
assert result == 'team'
def test_public_override_on_team_flow(self):
result = resolve_step_visibility(is_public=False, account_id=uuid4(), node_override='public')
assert result == 'public'
class TestExtractStepsForSync:
"""Test step extraction from tree structures."""
def test_extracts_procedure_steps_from_procedural_flow(self):
tree_structure = {
"steps": [
{"id": "step_1", "type": "procedure_step", "title": "Verify prerequisites",
"description": "Check all prereqs", "content_type": "action"},
{"id": "end_1", "type": "procedure_end", "title": "Done"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert len(results) == 1
assert results[0]['source_node_id'] == 'step_1'
assert results[0]['title'] == 'Verify prerequisites'
assert results[0]['step_type'] == 'action'
assert results[0]['content']['instructions'] == 'Check all prereqs'
def test_skips_section_header_nodes(self):
tree_structure = {
"steps": [
{"id": "sec_1", "type": "section_header", "title": "Phase 1"},
{"id": "step_1", "type": "procedure_step", "title": "First step",
"description": "Do this"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert len(results) == 1
assert results[0]['source_node_id'] == 'step_1'
def test_captures_section_header_as_group_label(self):
tree_structure = {
"steps": [
{"id": "sec_1", "type": "section_header", "title": "Cable Checks"},
{"id": "step_1", "type": "procedure_step", "title": "Check cable",
"description": "Verify cable is seated"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert results[0]['content']['group_label'] == 'Cable Checks'
def test_normalizes_string_commands(self):
tree_structure = {
"steps": [
{"id": "step_1", "type": "procedure_step", "title": "Run command",
"description": "Execute this", "commands": "ping 8.8.8.8"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert results[0]['content']['commands'] == [{"label": "", "command": "ping 8.8.8.8", "command_type": None}]
def test_normalizes_commandblock_commands(self):
tree_structure = {
"steps": [
{"id": "step_1", "type": "procedure_step", "title": "Run PS",
"description": "Run powershell",
"commands": [{"code": "Get-Service", "language": "powershell", "label": "Check services"}]},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
cmds = results[0]['content']['commands']
assert len(cmds) == 1
assert cmds[0]['command'] == 'Get-Service'
assert cmds[0]['command_type'] == 'powershell'
assert cmds[0]['label'] == 'Check services'
def test_extracts_action_and_solution_from_troubleshooting(self):
tree_structure = {
"id": "root",
"type": "decision",
"question": "What is wrong?",
"options": [{"id": "o1", "label": "Thing A", "next_node_id": "act_1"}],
"children": [
{"id": "act_1", "type": "action", "title": "Fix thing A",
"description": "Do the fix", "next_node_id": "sol_1",
"children": [{"id": "sol_1", "type": "solution", "title": "All fixed",
"description": "Problem resolved"}]},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='troubleshooting'))
node_ids = {r['source_node_id'] for r in results}
assert 'act_1' in node_ids
assert 'sol_1' in node_ids
types = {r['source_node_id']: r['step_type'] for r in results}
assert types['act_1'] == 'action'
assert types['sol_1'] == 'solution'
def test_uses_title_as_instructions_fallback(self):
tree_structure = {
"steps": [
{"id": "step_1", "type": "procedure_step", "title": "Do the thing"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert results[0]['content']['instructions'] == 'Do the thing'
def test_empty_steps_list(self):
tree_structure = {"steps": []}
results = list(extract_steps_for_sync(tree_structure, tree_type='procedural'))
assert results == []
def test_maintenance_treated_same_as_procedural(self):
tree_structure = {
"steps": [
{"id": "step_1", "type": "procedure_step", "title": "Maintenance step",
"description": "Do maintenance"},
]
}
results = list(extract_steps_for_sync(tree_structure, tree_type='maintenance'))
assert len(results) == 1
class TestSyncOnPublish:
"""Integration tests — sync triggered by publishing a tree."""
@pytest.mark.asyncio
async def test_publishing_procedural_tree_creates_library_steps(
self, client, auth_headers
):
# Create a procedural tree with two steps
tree_resp = await client.post("/api/v1/trees", json={
"name": "Test Procedure",
"tree_type": "procedural",
"status": "draft",
"tree_structure": {
"steps": [
{"id": "step_1", "type": "procedure_step",
"title": "First step", "description": "Do this first"},
{"id": "step_2", "type": "procedure_step",
"title": "Second step", "description": "Do this second"},
{"id": "end_1", "type": "procedure_end", "title": "Done"},
]
}
}, headers=auth_headers)
assert tree_resp.status_code == 201
tree_id = tree_resp.json()["id"]
# Publish the tree
pub_resp = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers)
assert pub_resp.status_code == 200
# Check library has synced entries
lib_resp = await client.get("/api/v1/steps", headers=auth_headers)
assert lib_resp.status_code == 200
steps = lib_resp.json()
synced = [s for s in steps if s.get("is_flow_synced")]
assert len(synced) == 2
titles = {s["title"] for s in synced}
assert "First step" in titles
assert "Second step" in titles
@pytest.mark.asyncio
async def test_republishing_updates_existing_library_steps(
self, client, auth_headers
):
# Create a draft tree first, then publish
tree_resp = await client.post("/api/v1/trees", json={
"name": "Update Test",
"tree_type": "procedural",
"status": "draft",
"tree_structure": {"steps": [
{"id": "step_1", "type": "procedure_step",
"title": "Original title", "description": "Original desc"},
{"id": "end_1", "type": "procedure_end", "title": "Done"},
]}
}, headers=auth_headers)
tree_id = tree_resp.json()["id"]
first_pub = await client.put(f"/api/v1/trees/{tree_id}", json={"status": "published"}, headers=auth_headers)
assert first_pub.status_code == 200
# Republish with updated step title
second_pub = await client.put(f"/api/v1/trees/{tree_id}", json={
"tree_structure": {"steps": [
{"id": "step_1", "type": "procedure_step",
"title": "Updated title", "description": "Updated desc"},
{"id": "end_1", "type": "procedure_end", "title": "Done"},
]},
"status": "published"
}, headers=auth_headers)
assert second_pub.status_code == 200
# Check library shows updated title (not a duplicate)
lib_resp = await client.get("/api/v1/steps", headers=auth_headers)
synced = [s for s in lib_resp.json() if s.get("is_flow_synced")]
assert len(synced) == 1
assert synced[0]["title"] == "Updated title"

View File

@@ -0,0 +1,108 @@
# Tree Forking UI Design
> **Date:** 2026-02-24
> **Feature:** Personal tree forking — explicit modal, reason capture, fork badge
---
## Overview
Add a proper fork UX to the flow library. The backend is fully complete (POST `/trees/:id/fork`, fork fields in API responses, tests passing). The frontend needs: a `ForkModal` component with a "Reason for Forking" field, updated fork handlers in `TreeLibraryPage` and `MyTreesPage`, fork field types on `Tree`, and a "Fork" chip on tree cards.
---
## What's Being Built
### 1. Types — `frontend/src/types/tree.ts`
Add `ForkInfo` interface and fork fields to `Tree`:
```ts
export interface ForkInfo {
parent_tree_id: string
parent_tree_name: string | null
fork_depth: number
fork_reason: string | null
has_parent_updates: boolean
}
```
Add to `Tree`:
```ts
fork_info?: ForkInfo | null
parent_tree_id?: string | null
fork_depth?: number
```
Add to `TreeCreate`:
```ts
fork_reason?: string
```
### 2. `ForkModal` Component — `frontend/src/components/library/ForkModal.tsx`
A focused dialog with:
- **Name field** — pre-filled with `"Copy of <original name>"`
- **"Reason for Forking"** — optional textarea (placeholder: "e.g. customizing for a specific client…")
- **Cancel** (secondary) + **Fork** (gradient) buttons
- Calls `treesApi.fork(treeId, { name, fork_reason })` on submit
- Success: shows toast, navigates to `/my-trees`
- Error: shows inline error, stays open
### 3. Update Fork Handlers
In `TreeLibraryPage` and `MyTreesPage`, replace the current silent `handleForkTree` (which calls `treesApi.fork()` directly) with a handler that:
1. Sets the selected tree to fork
2. Opens `ForkModal`
The modal handles the actual API call and navigation.
### 4. "Fork" Badge on Tree Cards
In `TreeGridView`, `TreeListView`, and `TreeTableView`, render a small chip when `fork_depth > 0` (or `parent_tree_id` is set on `TreeListItem`):
```tsx
{tree.fork_depth > 0 && (
<span className="rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
```
`fork_depth` needs to be added to `TreeListItem` (it comes from the backend list response).
---
## What's NOT Being Built
- Lineage tree view / "forked from" link — out of scope
- "Has updates available" notification — out of scope
- Fork management / ancestry tracking UI — out of scope
---
## Data Flow
```
User clicks "Fork" on a card
→ onForkTree(tree) called
→ parent sets forkTarget state + opens ForkModal
→ user fills Name + optional Reason
→ ForkModal calls treesApi.fork(treeId, { name, fork_reason })
→ on success: toast "Flow forked!" + navigate('/my-trees')
→ My Trees page loads, forked flow shows "Fork" badge
```
---
## Files Changed
| File | Change |
|------|--------|
| `frontend/src/types/tree.ts` | Add `ForkInfo`, fork fields on `Tree`, `fork_depth` on `TreeListItem` |
| `frontend/src/components/library/ForkModal.tsx` | New component |
| `frontend/src/pages/TreeLibraryPage.tsx` | Open modal instead of silent fork |
| `frontend/src/pages/MyTreesPage.tsx` | Open modal instead of silent fork |
| `frontend/src/components/library/TreeGridView.tsx` | Fork badge |
| `frontend/src/components/library/TreeListView.tsx` | Fork badge |
| `frontend/src/components/library/TreeTableView.tsx` | Fork badge |

View File

@@ -0,0 +1,187 @@
# Flow-to-Library Step Sync Design
> **Date:** 2026-02-25
> **Feature:** Automatically sync steps from published flows into the step library
---
## Overview
When a flow is published, its steps are extracted and written into the `step_library` table so engineers can discover and reuse them when building new flows or inserting ad-hoc steps during a live session. Library entries are flow-owned and read-only — forking creates a personal copy for customization.
**What gets synced:**
- `procedural` / `maintenance` flows → each `procedure_step` node → `step_type: 'action'`
- `troubleshooting` flows → each `action` node → `step_type: 'action'`; each `solution` node → `step_type: 'solution'`
- `section_header` and `procedure_end` nodes are NOT synced as library entries
**Sync trigger:** `PUT /trees/{tree_id}` when `status` transitions to `'published'`
**Sync model:** Upsert keyed on `(source_tree_id, source_node_id)` — subsequent publishes update existing entries without losing usage counts or ratings.
---
## Section 1: Data Model
### New columns on `step_library` (one migration)
| Column | Type | Default | Notes |
|--------|------|---------|-------|
| `source_tree_id` | UUID FK → `trees.id` | NULL | SET NULL on tree delete |
| `source_node_id` | String(255) | NULL | Node `id` within `tree_structure` JSONB |
| `is_flow_synced` | Boolean | `false` | Distinguishes synced from manually created entries |
| `last_synced_at` | DateTime(timezone=True) | NULL | Timestamp of last sync |
### New optional field on `StepContent` schema
Add `group_label: Optional[str] = None` to `StepContent` in `backend/app/schemas/step_library.py`. For procedural steps that belong to a section, this stores the section header title so steps are browsable/filterable by section in the library.
### Per-step visibility override on procedural step nodes
Add optional `library_visibility` field to individual step nodes in `tree_structure` JSONB:
- Type: `'team' | 'public'` (no `'private'` — synced steps are always at minimum team-visible)
- If absent: inherits visibility from the flow (default behavior)
- Stored directly on the step node in `tree_structure` — no schema migration needed (JSONB is flexible)
### Visibility inheritance mapping
| Flow state | Resolved step visibility |
|-----------|--------------------------|
| `is_public=True` | `'public'` |
| `is_public=False`, has `account_id` | `'team'` |
| `is_public=False`, no `account_id` | `'team'` |
| Step has `library_visibility` set | Use that value (overrides above) |
### On flow deactivation / deletion
When `is_active` is set to `False` on a tree, or the tree is deleted, soft-delete all synced library entries for that tree: `UPDATE step_library SET is_active=False WHERE source_tree_id=:tree_id AND is_flow_synced=True`.
Forked copies (`is_flow_synced=False`, different `created_by`) are unaffected.
---
## Section 2: Sync Logic (Backend)
### Trigger location
`backend/app/api/endpoints/trees.py``update_tree()` function, after the block at line ~587 where `status` is confirmed to be transitioning to `'published'`.
### Extraction logic
**For procedural/maintenance flows:**
```
steps = tree_structure.get('steps', [])
for node in steps:
if node['type'] != 'procedure_step':
continue
# find the most recent section_header preceding this step
group_label = last_seen_section_header_title
yield StepLibraryUpsert(
title=node['title'],
step_type='action',
content=StepContent(
instructions=node.get('description') or node['title'],
help_text=node.get('expected_outcome'),
commands=[StepCommand(label=c.get('label',''), command=c['code'], command_type=c.get('language'))
for c in normalize_commands(node.get('commands'))],
group_label=group_label,
),
visibility=node.get('library_visibility') or resolve_visibility(tree),
source_tree_id=tree.id,
source_node_id=node['id'],
)
```
**For troubleshooting flows:**
```
walk all nodes recursively
for node with type in ('action', 'solution'):
yield StepLibraryUpsert(
title=node['title'],
step_type='action' if node['type']=='action' else 'solution',
content=StepContent(
instructions=node.get('description') or node['title'],
),
visibility=resolve_visibility(tree),
source_tree_id=tree.id,
source_node_id=node['id'],
)
```
**Command normalization:** `node.commands` can be a plain string or an array of `{language, code, label}` objects. Normalize both into `StepCommand` list.
### Upsert query
```sql
INSERT INTO step_library (id, title, step_type, content, visibility, created_by,
account_id, is_flow_synced, source_tree_id, source_node_id, last_synced_at, ...)
VALUES (...)
ON CONFLICT (source_tree_id, source_node_id)
DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
visibility = EXCLUDED.visibility,
last_synced_at = EXCLUDED.last_synced_at,
is_active = true -- re-activate if previously soft-deleted
```
Requires a unique constraint on `(source_tree_id, source_node_id)`.
### `created_by` for synced entries
Set to the tree's `author_id`. This gives the flow author "ownership" of the entry, consistent with the flow-owned model. Permissions in `core/permissions.py` already allow the creator to see their own private steps — no change needed.
---
## Section 3: Per-Step Visibility Override (Editor)
### Where
`frontend/src/components/procedural-editor/StepEditor.tsx` — inside the existing "More Options" collapsible section.
### What
A **"Library Visibility"** select field, shown only for `procedure_step` nodes (not section headers, not end nodes):
```
Library Visibility
[ Inherit from flow ▼ ] (options: Inherit from flow / Team only / Public)
```
- Default (no `library_visibility` on node): renders as "Inherit from flow"
- Selecting "Team only" or "Public" writes `library_visibility: 'team'` or `library_visibility: 'public'` to the node
- Selecting "Inherit from flow" removes the `library_visibility` key from the node
Only rendered when `tree_type` is `'procedural'` or `'maintenance'`. Troubleshooting flows have no per-node override (they inherit the flow visibility always).
---
## Section 4: Frontend — Step Library Browser
### Changes to `StepLibraryBrowser` / step list
- **"From Flow" badge** on synced entries (`is_flow_synced: true`): small chip — same style as existing type badges. Shows source flow name.
- **Step detail/preview panel**: add "Sourced from: [Flow Name]" line with a link to the flow's navigate/edit page.
- **Read-only indicator**: for `is_flow_synced` entries, replace the Edit button with a lock icon + tooltip: "Managed by source flow — fork to customize."
- **Fork behavior**: existing "Save to Library" copy mechanism unchanged. Forked copy gets `is_flow_synced=false`, `source_tree_id=null`, `created_by=current_user`.
### API response changes
`StepLibraryResponse` needs two new fields:
- `is_flow_synced: bool`
- `source_tree_name: Optional[str]` — joined from `trees.name` at query time
---
## Files Changed
| File | Change |
|------|--------|
| `backend/alembic/versions/030_add_step_library_sync_fields.py` | New migration — add 4 columns + unique constraint |
| `backend/app/models/step_library.py` | Add 4 new columns + FK relationship to Tree |
| `backend/app/schemas/step_library.py` | Add `group_label` to `StepContent`; add `is_flow_synced` + `source_tree_name` to response schema |
| `backend/app/api/endpoints/trees.py` | Add sync logic after publish transition |
| `backend/app/core/step_sync.py` | New module — extraction + upsert logic (keeps trees.py clean) |
| `backend/tests/test_step_sync.py` | New test file |
| `frontend/src/types/step.ts` | Add `is_flow_synced`, `source_tree_name` to `Step` type |
| `frontend/src/components/procedural-editor/StepEditor.tsx` | Add Library Visibility select in More Options |
| `frontend/src/components/step-library/StepLibraryBrowser.tsx` | From Flow badge, read-only indicator, source flow link |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
# Tree Fork UI Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an explicit `ForkModal` with a "Reason for Forking" field to replace the silent fork flow, and show a "Fork" chip badge on forked tree cards in the library and My Trees views.
**Architecture:** The backend is fully complete (POST `/trees/:id/fork` accepts `{ name, fork_reason }`). The frontend `treesApi.fork()` already accepts these params. We need: (1) `ForkInfo` types added to `tree.ts`, (2) a new `ForkModal` component, (3) updated fork handlers in `TreeLibraryPage` and `MyTreesPage` to open the modal instead of forking silently, (4) a "Fork" chip in all three card views (grid, list, table).
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Lucide React, `treesApi.fork(id, { name, fork_reason })` already wired.
---
## Context for the Implementer
- `treesApi.fork(id, data?)` is at `frontend/src/api/trees.ts:42` — already accepts `{ fork_reason?, name? }`
- `onForkTree` prop exists on all three card views and currently passes only `treeId: string`
- `TreeLibraryPage` has `handleForkTree(treeId: string)` at line ~247 that calls `treesApi.fork(treeId)` silently
- `MyTreesPage` does NOT currently have a fork handler — the "Fork" UI there is an informational message (line ~215), not a button wired to `onForkTree`
- `TreeListItem` (used by all three views) does NOT yet have `fork_depth` or `parent_tree_id` — must add these
- `MyTreesPage` already uses `tree.parent_tree_id` at line ~283 for a "Forked from" display block — this field must be on the type for that to compile cleanly after our changes
- All three card views are in `frontend/src/components/library/`
- Design system: `bg-violet-400/15 text-violet-400` for the Fork chip; `bg-gradient-brand` for the Fork submit button; modal structure uses `bg-card border-border rounded-xl`
---
### Task 1: Add `ForkInfo` type and fork fields to `TreeListItem` and `Tree`
**Files:**
- Modify: `frontend/src/types/tree.ts:142-190`
This is a pure type change — no runtime behavior changes.
**Step 1: Add `ForkInfo` interface and fork fields**
In `frontend/src/types/tree.ts`, after line 141 (the `ProceduralTreeStructure` closing brace), add `ForkInfo` then update `Tree` and `TreeListItem`:
```typescript
export interface ForkInfo {
parent_tree_id: string
parent_tree_name: string | null
fork_depth: number
fork_reason: string | null
has_parent_updates: boolean
}
```
Add to `Tree` interface (after `usage_count: number`):
```typescript
fork_info?: ForkInfo | null
parent_tree_id?: string | null
fork_depth?: number
```
Add to `TreeListItem` interface (after `visibility` field):
```typescript
fork_depth?: number
parent_tree_id?: string | null
```
**Step 2: Verify TypeScript compiles cleanly**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
```
Expected: Clean build, no errors.
**Step 3: Commit**
```bash
cd /home/michaelchihlas/dev/patherly
git add frontend/src/types/tree.ts
git commit -m "feat: add ForkInfo type and fork fields to Tree/TreeListItem
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 2: Create `ForkModal` component
**Files:**
- Create: `frontend/src/components/library/ForkModal.tsx`
**Step 1: Create the component file**
Create `frontend/src/components/library/ForkModal.tsx` with this exact content:
```tsx
import { useState } from 'react'
import { GitBranch, X } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
interface ForkModalProps {
treeId: string
treeName: string
onClose: () => void
}
export function ForkModal({ treeId, treeName, onClose }: ForkModalProps) {
const navigate = useNavigate()
const [name, setName] = useState(`Copy of ${treeName}`)
const [forkReason, setForkReason] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
setError(null)
try {
await treesApi.fork(treeId, {
name: name.trim(),
fork_reason: forkReason.trim() || undefined,
})
toast.success('Flow forked successfully')
onClose()
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
setError('Failed to fork flow. Please try again.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Fork Flow</h2>
</div>
<button
onClick={onClose}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
className={cn(
'w-full rounded-lg border border-border bg-card 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>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Reason for Forking{' '}
<span className="text-muted-foreground/60">(optional)</span>
</label>
<textarea
value={forkReason}
onChange={(e) => setForkReason(e.target.value)}
rows={3}
placeholder="e.g. customizing for a specific client…"
className={cn(
'w-full resize-none rounded-lg border border-border bg-card 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>
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
{/* Footer */}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40"
>
{isSubmitting ? 'Forking…' : 'Fork Flow'}
</button>
</div>
</form>
</div>
</div>
)
}
```
**Step 2: Verify TypeScript compiles cleanly**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
```
Expected: Clean build, no errors.
**Step 3: Commit**
```bash
cd /home/michaelchihlas/dev/patherly
git add frontend/src/components/library/ForkModal.tsx
git commit -m "feat: add ForkModal component with name and reason fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 3: Update `TreeLibraryPage` to open `ForkModal`
**Files:**
- Modify: `frontend/src/pages/TreeLibraryPage.tsx`
The current `handleForkTree` at ~line 247 calls `treesApi.fork(treeId)` silently. Replace it with state that opens `ForkModal`.
**Step 1: Add import for `ForkModal` and `TreeListItem`**
At the top of `TreeLibraryPage.tsx`, the file already imports `TreeListItem` from `@/types`. Add `ForkModal` to the library component imports. Find the line that imports from `@/components/library/...` and add:
```tsx
import { ForkModal } from '@/components/library/ForkModal'
```
**Step 2: Replace fork state**
Find (around line 76):
```tsx
// Fork state
const [isForkingTree, setIsForkingTree] = useState(false)
```
Replace with:
```tsx
// Fork modal state
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
```
**Step 3: Replace `handleForkTree`**
Find (around line 247):
```tsx
const handleForkTree = async (treeId: string) => {
if (isForkingTree) return
setIsForkingTree(true)
try {
await treesApi.fork(treeId)
toast.success('Flow forked successfully')
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
toast.error('Failed to fork flow')
} finally {
setIsForkingTree(false)
}
}
```
Replace with:
```tsx
const handleForkTree = (treeId: string) => {
const tree = trees.find((t) => t.id === treeId)
if (tree) setForkTarget(tree)
}
```
Note: `trees` is the existing state variable holding the fetched tree list. If the variable is named differently in context, use the correct name.
**Step 4: Add `ForkModal` to JSX**
Find the closing `</div>` of the page's root element (near the end of the return statement, after all the other modals like `FolderEditModal`, `ConfirmDialog`). Add before the root closing tag:
```tsx
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
```
**Step 5: Verify TypeScript compiles cleanly**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
```
Expected: Clean build, no errors. If there are unused import errors for `treesApi` (if it was only used by the old `handleForkTree`), check whether `treesApi` is still used elsewhere on the page; if not, remove it from imports.
**Step 6: Commit**
```bash
cd /home/michaelchihlas/dev/patherly
git add frontend/src/pages/TreeLibraryPage.tsx
git commit -m "feat: open ForkModal on fork action in TreeLibraryPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 4: Update `MyTreesPage` to open `ForkModal`
**Files:**
- Modify: `frontend/src/pages/MyTreesPage.tsx`
`MyTreesPage` does NOT currently pass `onForkTree` to any view components — the page is a custom hand-rolled list, not using the three card views. The fork action is not wired here. However, `tree.parent_tree_id` is already rendered (line ~283), so we just need to add a `ForkModal` trigger for any fork buttons that may be present.
**Step 1: Read the MyTreesPage fork section carefully**
Read lines 200300 of `frontend/src/pages/MyTreesPage.tsx` to understand the exact current fork UI and whether there's a fork button.
```bash
sed -n '200,300p' /home/michaelchihlas/dev/patherly/frontend/src/pages/MyTreesPage.tsx
```
**Step 2: Add import for `ForkModal`**
Add to the imports:
```tsx
import { ForkModal } from '@/components/library/ForkModal'
```
**Step 3: Add fork modal state**
Find the state declarations section. Add:
```tsx
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
```
**Step 4: Add a "Fork" button to each tree row (if not already present)**
In the tree list rendering, find the action buttons area for each tree (look for the edit/delete buttons). Add a Fork button next to them:
```tsx
<button
type="button"
onClick={() => setForkTarget(tree)}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Fork flow"
>
<GitBranch className="h-4 w-4" />
</button>
```
Note: `GitBranch` is already imported in `MyTreesPage` (line 3).
**Step 5: Add `ForkModal` to JSX**
Find the end of the return statement. Before the root closing tag, add:
```tsx
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
```
**Step 6: Verify TypeScript compiles cleanly**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
```
Expected: Clean build, no errors.
**Step 7: Commit**
```bash
cd /home/michaelchihlas/dev/patherly
git add frontend/src/pages/MyTreesPage.tsx
git commit -m "feat: add ForkModal to MyTreesPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
### Task 5: Add "Fork" chip badge to all three card views
**Files:**
- Modify: `frontend/src/components/library/TreeGridView.tsx`
- Modify: `frontend/src/components/library/TreeListView.tsx`
- Modify: `frontend/src/components/library/TreeTableView.tsx`
Show a small violet chip when `tree.fork_depth > 0` (or `tree.parent_tree_id` is set). Place it near the tree name or alongside other metadata chips.
**Step 1: Add Fork chip to `TreeGridView`**
Read `frontend/src/components/library/TreeGridView.tsx` lines 60100 to find where tree name and category badge are rendered.
In the name/header area of each card (near where `tree.category_info` chip is rendered), add:
```tsx
{(tree.fork_depth ?? 0) > 0 && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
```
Place this chip alongside or just after the tree name `<span>`, or next to the category badge — wherever fits the card layout (read the file to confirm exact placement).
**Step 2: Add Fork chip to `TreeListView`**
Read `frontend/src/components/library/TreeListView.tsx` lines 60130 to find the name + metadata row.
Add the same chip in the same relative position:
```tsx
{(tree.fork_depth ?? 0) > 0 && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
```
**Step 3: Add Fork chip to `TreeTableView`**
Read `frontend/src/components/library/TreeTableView.tsx` lines 80150 to find the name column cell.
Add the same chip inline after the tree name in the name column:
```tsx
{(tree.fork_depth ?? 0) > 0 && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
```
**Step 4: Verify TypeScript compiles cleanly**
```bash
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
```
Expected: Clean build, no errors.
**Step 5: Commit**
```bash
cd /home/michaelchihlas/dev/patherly
git add frontend/src/components/library/TreeGridView.tsx \
frontend/src/components/library/TreeListView.tsx \
frontend/src/components/library/TreeTableView.tsx
git commit -m "feat: show Fork chip badge on forked tree cards
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
---
## Manual Verification Checklist
After all tasks are complete:
1. **Fork flow (Library):** Go to Flow Library → click GitBranch icon on any published flow → `ForkModal` opens with name pre-filled as "Copy of <name>" → enter a reason → click "Fork Flow" → toast appears → redirected to My Trees.
2. **Fork flow (My Trees):** Go to My Trees → find a flow → click Fork button → same modal + behavior.
3. **Fork badge:** Fork a flow → go to My Trees → forked flow shows violet "Fork" chip in card header.
4. **Badge in Library views:** In Flow Library, switch to grid/list/table view — forked flows (your own) show "Fork" chip.
5. **Reason is optional:** Fork a flow without entering a reason → still works.
6. **Cancel:** Open ForkModal → click Cancel → modal closes, nothing forked.

View File

@@ -5,6 +5,7 @@ export interface SessionListParams {
page?: number
size?: number
tree_id?: string
batch_id?: string
completed?: boolean
ticket_number?: string
client_name?: string

View File

@@ -0,0 +1,138 @@
import { useState, useEffect } from 'react'
import { GitBranch, X } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
interface ForkModalProps {
treeId: string
treeName: string
onClose: () => void
}
export function ForkModal({ treeId, treeName, onClose }: ForkModalProps) {
const navigate = useNavigate()
const [name, setName] = useState(`Copy of ${treeName}`)
const [forkReason, setForkReason] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
setError(null)
try {
await treesApi.fork(treeId, {
name: name.trim(),
fork_reason: forkReason.trim() || undefined,
})
toast.success('Flow forked successfully')
onClose()
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
setError('Failed to fork flow. Please try again.')
} finally {
setIsSubmitting(false)
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Fork Flow</h2>
</div>
<button
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="space-y-4 px-5 py-4">
<div>
<label htmlFor="fork-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Name <span className="text-red-400">*</span>
</label>
<input
id="fork-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
maxLength={255}
className={cn(
'w-full rounded-lg border border-border bg-card 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>
<div>
<label htmlFor="fork-reason" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Reason for Forking{' '}
<span className="text-muted-foreground/60">(optional)</span>
</label>
<textarea
id="fork-reason"
value={forkReason}
onChange={(e) => setForkReason(e.target.value)}
rows={3}
placeholder="e.g. customizing for a specific client…"
className={cn(
'w-full resize-none rounded-lg border border-border bg-card 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>
{error && (
<p className="text-xs text-red-400">{error}</p>
)}
{/* Footer */}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Forking…' : 'Fork Flow'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -52,6 +52,11 @@ export function TreeGridView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
</div>
<div className="flex items-center gap-2">
{onTogglePin && (

View File

@@ -53,6 +53,11 @@ export function TreeListView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -182,6 +182,11 @@ export function TreeTableView({
Maintenance
</span>
)}
{'fork_info' in tree && Boolean((tree as Record<string, unknown>).fork_info) && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
{tree.is_public ? (
<span title="Public tree">
<Globe className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />

View File

@@ -0,0 +1,46 @@
import { X, RefreshCw, ChevronRight } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import type { Session } from '@/types'
interface ActiveBatchBannerProps {
treeId: string
sessions: Session[]
onDismiss: () => void
}
export function ActiveBatchBanner({ treeId, sessions, onDismiss }: ActiveBatchBannerProps) {
const navigate = useNavigate()
// Find the most recently started in-progress batch
const inProgressSessions = sessions.filter(s => s.started_at && !s.completed_at && s.batch_id)
if (inProgressSessions.length === 0) return null
const batchId = inProgressSessions[0].batch_id!
// Count all sessions in this batch
const batchSessions = sessions.filter(s => s.batch_id === batchId)
const completed = batchSessions.filter(s => s.completed_at).length
const total = batchSessions.length
return (
<div className="flex items-center gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-amber-400" />
<p className="flex-1 text-[0.875rem] text-amber-300">
Batch in progress · <span className="font-medium">{completed} of {total}</span> targets complete
</p>
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="flex items-center gap-1 text-[0.8125rem] font-medium text-amber-400 hover:text-amber-300 transition-colors"
>
View Batch
<ChevronRight className="h-3.5 w-3.5" />
</button>
<button
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
}

View File

@@ -0,0 +1,144 @@
import { CheckCircle, Circle, Loader2, Play, RotateCcw, Eye } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { cn } from '@/lib/utils'
import type { Session } from '@/types'
interface BatchStatusCardProps {
session: Session | null
targetLabel: string
treeId: string
batchId: string
}
function formatDuration(startedAt: string, completedAt: string): string {
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime()
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
if (minutes === 0) return `${seconds}s`
return `${minutes}m ${seconds}s`
}
const OUTCOME_LABELS: Record<string, string> = {
resolved: 'Resolved',
escalated: 'Escalated',
workaround: 'Workaround',
unresolved: 'Unresolved',
}
const OUTCOME_COLORS: Record<string, string> = {
resolved: 'text-emerald-400 bg-emerald-500/10',
escalated: 'text-red-400 bg-red-500/10',
workaround: 'text-amber-400 bg-amber-500/10',
unresolved: 'text-muted-foreground bg-muted',
}
export function BatchStatusCard({ session, targetLabel, treeId, batchId }: BatchStatusCardProps) {
const navigate = useNavigate()
const isComplete = !!session?.completed_at
const isInProgress = !!session?.started_at && !session.completed_at
const isNotStarted = !session || !session.started_at
// Derive step progress for in-progress sessions
const stepProgress = (() => {
if (!session || !isInProgress) return null
const snapshot = session.tree_snapshot as { steps?: { type: string }[] } | null
const totalSteps = snapshot?.steps?.filter(s => s.type === 'procedure_step').length ?? 0
const completedSteps = (session.decisions ?? []).filter(d => d.answer === 'completed').length
return totalSteps > 0 ? { completed: completedSteps, total: totalSteps } : null
})()
const handleStart = () => {
navigate(`/flows/${treeId}/navigate`, {
state: { targetLabel, batchId },
})
}
const handleResume = () => {
if (!session) return
navigate(`/flows/${treeId}/navigate`, {
state: { sessionId: session.id },
})
}
const handleView = () => {
if (!session) return
navigate(`/sessions/${session.id}`)
}
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 gap-4">
{/* Status indicator + target name */}
<div className="flex items-center gap-3 min-w-0">
{isComplete && (
<CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
)}
{isInProgress && (
<Loader2 className="h-4 w-4 shrink-0 text-amber-400 animate-spin" />
)}
{isNotStarted && (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<p className="text-[0.875rem] font-medium text-foreground truncate">{targetLabel}</p>
<div className="flex items-center gap-2 mt-0.5">
{isComplete && session?.outcome && (
<span className={cn(
"font-label text-[0.625rem] uppercase tracking-wide rounded-full px-2 py-0.5",
OUTCOME_COLORS[session.outcome] ?? OUTCOME_COLORS.unresolved
)}>
{OUTCOME_LABELS[session.outcome] ?? session.outcome}
</span>
)}
{isComplete && session?.started_at && session?.completed_at && (
<span className="text-[0.75rem] text-muted-foreground">
{formatDuration(session.started_at, session.completed_at)}
</span>
)}
{isInProgress && stepProgress && (
<span className="text-[0.75rem] text-amber-400">
Step {stepProgress.completed + 1} of {stepProgress.total}
</span>
)}
{isNotStarted && (
<span className="text-[0.75rem] text-muted-foreground">Not started</span>
)}
</div>
</div>
</div>
{/* Action button */}
<div className="shrink-0">
{isComplete && (
<button
onClick={handleView}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-3.5 w-3.5" />
View
</button>
)}
{isInProgress && (
<button
onClick={handleResume}
className="flex items-center gap-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 px-3 py-1.5 text-[0.8125rem] text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Resume
</button>
)}
{isNotStarted && (
<button
onClick={handleStart}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-3 py-1.5 text-[0.8125rem] font-medium text-white shadow-sm shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Play className="h-3.5 w-3.5" />
Start
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { Wrench, ChevronLeft } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface MaintenanceContextStripProps {
treeId: string
targetLabel?: string | null
batchId?: string | null
batchProgress?: { completed: number; total: number } | null
}
export function MaintenanceContextStrip({
treeId,
targetLabel,
batchId,
batchProgress,
}: MaintenanceContextStripProps) {
const navigate = useNavigate()
return (
<div className="flex items-center gap-3 border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm">
<Wrench className="h-3.5 w-3.5 shrink-0 text-amber-400" />
<span className="font-medium text-amber-300">
{targetLabel ? `Target: ${targetLabel}` : 'Manual Run'}
</span>
{batchProgress && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground text-[0.8125rem]">
{batchProgress.completed} / {batchProgress.total} complete
</span>
</>
)}
{batchId && (
<button
onClick={() => navigate(`/flows/${treeId}/batches/${batchId}`)}
className="ml-auto flex items-center gap-1 text-xs text-amber-400 hover:text-amber-300 transition-colors"
>
<ChevronLeft className="h-3 w-3" />
Back to Batch
</button>
)}
</div>
)
}

View File

@@ -254,6 +254,28 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
</label>
</div>
</div>
{/* Library Visibility — procedure_step nodes only */}
{step.type === 'procedure_step' && <div>
<label htmlFor="library-visibility" className="mb-1.5 block text-xs font-medium text-muted-foreground">
Library Visibility
</label>
<select
id="library-visibility"
value={step.library_visibility ?? ''}
onChange={(e) => onUpdate({
library_visibility: e.target.value === '' ? undefined : e.target.value as 'team' | 'public'
})}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="">Inherit from flow</option>
<option value="team">Team only</option>
<option value="public">Public</option>
</select>
<p className="mt-1 text-[10px] text-muted-foreground">
Controls visibility in the step library. Defaults to the flow's own visibility setting.
</p>
</div>}
</div>
)}
</div>

View File

@@ -1,9 +1,9 @@
import { CheckCircle2, Circle, ArrowRight } from 'lucide-react'
import type { ProceduralStep } from '@/types'
import type { RuntimeStep } from '@/types'
import { cn } from '@/lib/utils'
interface StepChecklistProps {
steps: ProceduralStep[]
steps: RuntimeStep[]
currentStepIndex: number
completedStepIds: Set<string>
onStepClick: (index: number) => void
@@ -16,7 +16,8 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
const sectionVisibility = new Set<number>()
let prevSection: string | undefined
for (let i = 0; i < procedureSteps.length; i++) {
const header = procedureSteps[i].section_header
const s = procedureSteps[i]
const header = 'section_header' in s ? s.section_header : undefined
if (header && header !== prevSection) sectionVisibility.add(i)
if (header) prevSection = header
}
@@ -32,7 +33,7 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
<div key={step.id}>
{showSection && (
<div className="mb-1 mt-3 border-b border-border pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground first:mt-0">
{step.section_header}
{'section_header' in step ? step.section_header : undefined}
</div>
)}
<button
@@ -54,8 +55,15 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-medium">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate">{step.title || 'Untitled step'}</span>
{step.estimated_minutes && (
<span className="min-w-0 flex-1 flex items-center gap-1.5 overflow-hidden">
<span className="truncate">{step.title || 'Untitled step'}</span>
{'isCustom' in step && step.isCustom && (
<span className="shrink-0 rounded-full bg-amber-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-400">
Custom
</span>
)}
</span>
{'estimated_minutes' in step && step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">~{step.estimated_minutes}m</span>
)}
</button>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { AlertTriangle, CheckCircle2, Info, Zap, Copy, Check, ExternalLink } from 'lucide-react'
import type { ProceduralStep, StepContentType, CommandBlock } from '@/types'
import type { RuntimeStep, StepContentType, CommandBlock } from '@/types'
import { resolveVariables } from '@/lib/variableResolver'
import { cn } from '@/lib/utils'
@@ -12,7 +12,7 @@ const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: stri
}
interface StepDetailProps {
step: ProceduralStep
step: RuntimeStep
stepNumber: number
totalSteps: number
variables: Record<string, string>
@@ -39,13 +39,18 @@ export function StepDetail({
isLast,
}: StepDetailProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
const isCustom = 'isCustom' in step && step.isCustom
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
// Derive verification from either flat fields or nested object
const verificationPrompt = step.verification_prompt || step.verification?.prompt
const verificationType = step.verification_type || step.verification?.type
const verificationPrompt = !isCustom && 'verification_prompt' in step
? step.verification_prompt || step.verification?.prompt
: undefined
const verificationType = !isCustom && 'verification_type' in step
? step.verification_type || step.verification?.type
: undefined
const resolve = (text: string | undefined) => {
if (!text) return ''
@@ -87,14 +92,20 @@ export function StepDetail({
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold text-foreground">{step.title}</h2>
<div className="mt-1 flex items-center gap-2">
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
<Icon className="h-3 w-3" />
{config.label}
</span>
{isCustom ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/15 px-2 py-0.5 text-xs text-amber-400">
Custom Step
</span>
) : (
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
<Icon className="h-3 w-3" />
{config.label}
</span>
)}
<span className="text-xs text-muted-foreground">
Step {stepNumber} of {totalSteps}
</span>
{step.estimated_minutes && (
{'estimated_minutes' in step && step.estimated_minutes && (
<span className="text-xs text-muted-foreground">~{step.estimated_minutes} min</span>
)}
</div>
@@ -102,7 +113,7 @@ export function StepDetail({
</div>
{/* Warning banner */}
{step.warning_text && (
{'warning_text' in step && step.warning_text && (
<div className="flex items-start gap-2 rounded-lg border border-yellow-400/20 bg-yellow-400/5 px-3 py-2.5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-400" />
<p className="text-sm text-yellow-200">{resolve(step.warning_text)}</p>
@@ -142,7 +153,7 @@ export function StepDetail({
)}
{/* Expected outcome */}
{step.expected_outcome && (
{'expected_outcome' in step && step.expected_outcome && (
<div className="rounded-lg border border-border bg-white/[0.02] p-3">
<h4 className="mb-1 text-xs font-medium text-muted-foreground">Expected Outcome</h4>
<p className="text-sm text-muted-foreground">{resolve(step.expected_outcome)}</p>
@@ -181,7 +192,7 @@ export function StepDetail({
)}
{/* Notes */}
{step.notes_enabled !== false && (
{(!('notes_enabled' in step) || step.notes_enabled !== false) && (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Notes</label>
<textarea
@@ -195,7 +206,7 @@ export function StepDetail({
)}
{/* Reference link */}
{step.reference_url && (
{'reference_url' in step && step.reference_url && (
<a
href={resolve(step.reference_url)}
target="_blank"

View File

@@ -1,11 +1,15 @@
import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle, Pencil, Trash2, Bookmark, Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { StepListItem } from '@/types/step'
interface StepCardProps {
step: StepListItem
onPreview: (step: StepListItem) => void
onInsert: (step: StepListItem) => void
onInsert?: (step: StepListItem) => void // session context (now optional)
onEdit?: (step: StepListItem) => void // library page
onDelete?: (step: StepListItem) => void // library page — NOTE: pass full StepListItem, not just ID
onSave?: (step: StepListItem) => void // library page (save copy to My Steps)
currentUserId?: string // to determine ownership
}
const stepTypeIcons = {
@@ -20,12 +24,14 @@ const stepTypeColors = {
solution: 'bg-emerald-400/10 text-emerald-400 border-emerald-400/20'
}
export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
export function StepCard({ step, onPreview, onInsert, onEdit, onDelete, onSave, currentUserId }: StepCardProps) {
const Icon = stepTypeIcons[step.step_type as keyof typeof stepTypeIcons] || HelpCircle
const hasRating = step.rating_count > 0
const visibleTags = step.tags.slice(0, 3)
const remainingTags = step.tags.length - 3
const isOwn = currentUserId ? step.created_by === currentUserId : false
return (
<div className="group rounded-lg border border-border bg-card p-4 transition-shadow hover:shadow-md">
{/* Header */}
@@ -49,6 +55,13 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
Featured
</span>
)}
{/* From Flow Badge */}
{step.is_flow_synced && (
<span className="rounded-full bg-blue-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-blue-400">
From Flow
</span>
)}
</div>
{/* Title */}
@@ -118,26 +131,89 @@ export function StepCard({ step, onPreview, onInsert }: StepCardProps) {
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => onPreview(step)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
'hover:bg-accent hover:text-foreground transition-colors'
)}
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onInsert(step)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 transition-colors'
)}
>
<Plus className="h-4 w-4" />
Insert
</button>
{(onEdit || onDelete || onSave) ? (
isOwn ? (
step.is_flow_synced ? (
// Flow-synced step: Preview + lock (read-only)
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<span
title="Managed by source flow — fork to customize"
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground opacity-50 cursor-default"
>
<Lock className="h-4 w-4" />
</span>
</>
) : (
// Own step: Preview + Edit + Delete icon
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onEdit?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Pencil className="h-4 w-4" />
Edit
</button>
<button
onClick={() => onDelete?.(step)}
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground hover:bg-red-400/10 hover:text-red-400 hover:border-red-400/30 transition-colors"
aria-label="Delete step"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)
) : (
// Others' step: Preview + Save
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onSave?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
>
<Bookmark className="h-4 w-4" />
Save
</button>
</>
)
) : (
// Session context (original): Preview + Insert
<>
<button
onClick={() => onPreview(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={() => onInsert?.(step)}
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
>
<Plus className="h-4 w-4" />
Insert
</button>
</>
)}
</div>
</div>
)

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { X, Star, Copy, Check, HelpCircle, Zap, CheckCircle, User, Calendar } from 'lucide-react'
import { X, Star, Copy, Check, HelpCircle, Zap, CheckCircle, User, Calendar, GitBranch } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { stepsApi } from '@/api/steps'
@@ -283,6 +283,12 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
<span className="text-muted-foreground">Author:</span>
<span className="ml-2 font-medium text-foreground">{step.author_name || 'Unknown'}</span>
</div>
{step.is_flow_synced && step.source_tree_name && (
<div className="col-span-2 flex items-center gap-1.5 text-muted-foreground">
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span>Sourced from <span className="font-medium text-foreground">{step.source_tree_name}</span></span>
</div>
)}
<div>
<span className="text-muted-foreground">Usage Count:</span>
<span className="ml-2 font-medium text-foreground">{step.usage_count}</span>

View File

@@ -8,6 +8,8 @@ interface StepFormProps {
onSubmit: (data: StepCreate) => void
onCancel: () => void
initialData?: Partial<StepCreate>
submitLabel?: string
isSubmitting?: boolean
}
const stepTypeOptions = [
@@ -16,7 +18,7 @@ const stepTypeOptions = [
{ value: 'solution', label: 'Solution', icon: CheckCircle, description: 'Resolution endpoint' }
] as const
export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmitting }: StepFormProps) {
// Form state
const [stepType, setStepType] = useState<'decision' | 'action' | 'solution'>(
initialData?.step_type || 'action'
@@ -376,9 +378,10 @@ export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) {
</button>
<button
type="submit"
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90"
disabled={isSubmitting}
className="flex-1 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
Insert Step
{isSubmitting ? 'Saving...' : (submitLabel ?? 'Insert Step')}
</button>
</div>
</form>

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { stepsApi } from '@/api/steps'
import { StepForm } from './StepForm'
import type { Step, StepCreate } from '@/types/step'
interface StepFormModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (step: Step) => void
editingStep?: Step | null // full Step (parent fetches before opening), null/undefined = create mode
}
export function StepFormModal({ isOpen, onClose, onSuccess, editingStep }: StepFormModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const isEditMode = !!editingStep
const handleSubmit = async (data: StepCreate) => {
setIsSubmitting(true)
setError(null)
try {
let result: Step
if (isEditMode && editingStep) {
result = await stepsApi.update(editingStep.id, data)
} else {
result = await stepsApi.create(data)
}
onSuccess(result)
} catch (err) {
console.error('Failed to save step:', err)
setError('Failed to save step. Please try again.')
} finally {
setIsSubmitting(false)
}
}
// Build initialData from full Step including content
const initialData = editingStep ? {
title: editingStep.title,
step_type: editingStep.step_type,
content: editingStep.content,
visibility: editingStep.visibility,
category_id: editingStep.category_id,
tags: editingStep.tags,
} : undefined
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="relative flex h-[90vh] w-full max-w-2xl flex-col bg-card border border-border rounded-2xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-6 pb-4">
<h2 className="text-lg font-semibold text-foreground">
{isEditMode ? 'Edit Step' : 'Create Step'}
</h2>
<button
onClick={onClose}
disabled={isSubmitting}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Error */}
{error && (
<div className="mx-6 mt-4 rounded-lg border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
<StepForm
onSubmit={handleSubmit}
onCancel={onClose}
initialData={initialData}
submitLabel={isEditMode ? 'Save Changes' : 'Create Step'}
isSubmitting={isSubmitting}
/>
</div>
</div>
</div>
)
}

View File

@@ -8,12 +8,17 @@ import { StepDetailModal } from './StepDetailModal'
import type { Step, StepListItem, StepCategory, PopularTag, StepListParams } from '@/types/step'
interface StepLibraryBrowserProps {
onInsert: (step: Step) => void
onInsert?: (step: Step) => void
onCreateNew?: () => void
showCreateButton?: boolean
onEdit?: (step: StepListItem) => void
onDelete?: (step: StepListItem) => void
onSave?: (step: StepListItem) => void
currentUserId?: string
refreshKey?: number
}
export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = false }: StepLibraryBrowserProps) {
export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = false, onEdit, onDelete, onSave, currentUserId, refreshKey }: StepLibraryBrowserProps) {
// State
const [steps, setSteps] = useState<StepListItem[]>([])
const [categories, setCategories] = useState<StepCategory[]>([])
@@ -87,7 +92,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
}
loadSteps()
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag])
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey])
// Group steps by visibility
const groupedSteps = useMemo(() => {
@@ -108,12 +113,15 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
const handleInsertFromPreview = (step: Step) => {
setPreviewStepId(null)
onInsert(step)
if (onInsert) {
onInsert(step)
}
}
const handleInsertFromCard = (stepItem: StepListItem) => {
// Need to fetch full step details for insert
stepsApi.get(stepItem.id).then(onInsert)
if (onInsert) {
stepsApi.get(stepItem.id).then(onInsert)
}
}
const handleTagClick = (tag: string) => {
@@ -275,7 +283,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
key={step.id}
step={step}
onPreview={handlePreview}
onInsert={handleInsertFromCard}
onInsert={onInsert ? handleInsertFromCard : undefined}
onEdit={onEdit}
onDelete={onDelete}
onSave={onSave}
currentUserId={currentUserId}
/>
))}
</div>
@@ -304,7 +316,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
key={step.id}
step={step}
onPreview={handlePreview}
onInsert={handleInsertFromCard}
onInsert={onInsert ? handleInsertFromCard : undefined}
onEdit={onEdit}
onDelete={onDelete}
onSave={onSave}
currentUserId={currentUserId}
/>
))}
</div>
@@ -333,7 +349,11 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
key={step.id}
step={step}
onPreview={handlePreview}
onInsert={handleInsertFromCard}
onInsert={onInsert ? handleInsertFromCard : undefined}
onEdit={onEdit}
onDelete={onDelete}
onSave={onSave}
currentUserId={currentUserId}
/>
))}
</div>

View File

@@ -0,0 +1,209 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ChevronLeft, RefreshCw, Wrench } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { BatchStatusCard } from '@/components/maintenance/BatchStatusCard'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import type { Tree, Session } from '@/types'
// Batch sessions are created with a shared batch_id and individual target_labels.
// Some targets may not have a session yet if created via a pre-populated target list
// where sessions were created all at once — in practice all sessions exist at batch
// launch time, so we group by target_label from the sessions we get back.
export default function BatchStatusPage() {
const { id: treeId, batchId } = useParams<{ id: string; batchId: string }>()
const navigate = useNavigate()
const [tree, setTree] = useState<Tree | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [batchDate, setBatchDate] = useState<Date | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loadSessions = useCallback(async (showRefreshing = false) => {
if (!batchId) return
if (showRefreshing) setIsRefreshing(true)
try {
const data = await sessionsApi.list({ batch_id: batchId, size: 100 })
setSessions(Array.isArray(data) ? data : [])
if (data.length > 0 && data[0].started_at) {
setBatchDate(new Date(data[0].started_at))
}
} finally {
if (showRefreshing) setIsRefreshing(false)
}
}, [batchId])
// Initial load
useEffect(() => {
if (!treeId || !batchId) return
const load = async () => {
try {
const [treeData] = await Promise.all([
treesApi.get(treeId),
loadSessions(),
])
setTree(treeData)
} finally {
setIsLoading(false)
}
}
load()
}, [treeId, batchId, loadSessions])
// Polling: refresh every 5s while any session is in-progress
useEffect(() => {
const hasInProgress = sessions.some(s => s.started_at && !s.completed_at)
if (hasInProgress) {
pollRef.current = setInterval(() => loadSessions(), 5000)
} else {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [sessions, loadSessions])
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="sm" className="h-6 w-6 border-primary border-t-transparent" />
</div>
)
}
const total = sessions.length
const completed = sessions.filter(s => s.completed_at).length
const inProgress = sessions.filter(s => s.started_at && !s.completed_at).length
const allDone = total > 0 && completed === total
// Outcome summary for completion
const outcomeCounts = sessions.reduce<Record<string, number>>((acc, s) => {
if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1
return acc
}, {})
const progressPercent = total > 0 ? Math.round((completed / total) * 100) : 0
return (
<div className="container mx-auto max-w-3xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Breadcrumb */}
<div className="flex items-center justify-between">
<button
onClick={() => treeId && navigate(`/flows/${treeId}/maintenance`)}
className="flex items-center gap-1.5 text-[0.875rem] text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{tree?.name ?? 'Maintenance Flow'}
</button>
<button
onClick={() => loadSessions(true)}
disabled={isRefreshing}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50"
>
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
Refresh
</button>
</div>
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-400">
<Wrench className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Batch Run</h1>
{batchDate && (
<p className="text-[0.875rem] text-muted-foreground">
{batchDate.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
</p>
)}
</div>
</div>
{/* Progress bar */}
{total > 0 && (
<div className="rounded-xl border border-border bg-card p-5 space-y-3">
<div className="flex items-center justify-between text-[0.875rem]">
<span className="font-medium text-foreground">
{completed} of {total} complete
</span>
<span className={cn(
'font-label text-[0.6875rem] uppercase tracking-wide rounded-full px-2 py-0.5',
allDone
? 'text-emerald-400 bg-emerald-500/10'
: inProgress > 0
? 'text-amber-400 bg-amber-500/10'
: 'text-muted-foreground bg-muted'
)}>
{allDone ? 'Complete' : inProgress > 0 ? `${inProgress} in progress` : 'Not started'}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-500',
allDone ? 'bg-emerald-500' : 'bg-amber-500'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Completion summary */}
{allDone && Object.keys(outcomeCounts).length > 0 && (
<div className="flex flex-wrap gap-3 pt-1">
{outcomeCounts.resolved && (
<span className="text-[0.8125rem] text-emerald-400">{outcomeCounts.resolved} resolved</span>
)}
{outcomeCounts.escalated && (
<span className="text-[0.8125rem] text-red-400">{outcomeCounts.escalated} escalated</span>
)}
{outcomeCounts.workaround && (
<span className="text-[0.8125rem] text-amber-400">{outcomeCounts.workaround} workaround</span>
)}
{outcomeCounts.unresolved && (
<span className="text-[0.8125rem] text-muted-foreground">{outcomeCounts.unresolved} unresolved</span>
)}
</div>
)}
</div>
)}
{/* Target cards */}
<div className="space-y-2">
{sessions.length === 0 ? (
<p className="text-center text-[0.875rem] text-muted-foreground py-8">
No sessions found for this batch.
</p>
) : (
sessions
.sort((a, b) => {
// Sort: in-progress first, then not-started, then complete
const rank = (s: Session) => {
if (s.started_at && !s.completed_at) return 0
if (!s.started_at) return 1
return 2
}
return rank(a) - rank(b)
})
.map((session) => (
<BatchStatusCard
key={session.id}
session={session}
targetLabel={session.target_label ?? session.id}
treeId={treeId!}
batchId={batchId!}
/>
))
)}
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal'
import { ActiveBatchBanner } from '@/components/maintenance/ActiveBatchBanner'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { PageHeader } from '@/components/common/PageHeader'
@@ -12,6 +13,13 @@ import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { Tree, MaintenanceSchedule, Session } from '@/types'
const OUTCOME_LABELS: Record<string, string> = {
resolved: 'resolved',
escalated: 'escalated',
workaround: 'workaround',
unresolved: 'unresolved',
}
export default function MaintenanceFlowDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -20,6 +28,8 @@ export default function MaintenanceFlowDetailPage() {
const [recentSessions, setRecentSessions] = useState<Session[]>([])
const [showBatchModal, setShowBatchModal] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isRunning, setIsRunning] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(false)
useEffect(() => {
if (!id) return
@@ -33,7 +43,6 @@ export default function MaintenanceFlowDetailPage() {
}
setTree(treeData)
// Load recent sessions for this tree
try {
const sessionData = await sessionsApi.list({ tree_id: id, size: 30 })
setRecentSessions(Array.isArray(sessionData) ? sessionData : [])
@@ -41,7 +50,6 @@ export default function MaintenanceFlowDetailPage() {
// Sessions load is optional
}
// Try to load schedule (404 is fine)
try {
const sched = await maintenanceSchedulesApi.getForTree(id)
setSchedule(sched)
@@ -58,10 +66,21 @@ export default function MaintenanceFlowDetailPage() {
load()
}, [id, navigate])
const handleLaunched = (_batchId: string, count: number) => {
const handleLaunched = (batchId: string, _count: number) => {
setShowBatchModal(false)
toast.success(`${count} sessions created — view them in Sessions`)
navigate('/sessions')
setBannerDismissed(false)
// Reload sessions so banner picks up the new batch
if (id) {
sessionsApi.list({ tree_id: id, size: 30 })
.then(data => setRecentSessions(Array.isArray(data) ? data : []))
.catch(() => {})
}
navigate(`/flows/${id}/batches/${batchId}`)
}
const handleRun = () => {
setIsRunning(true)
navigate(`/flows/${id}/navigate`)
}
if (isLoading) {
@@ -100,8 +119,20 @@ export default function MaintenanceFlowDetailPage() {
}
const batches = Array.from(batchMap.entries()).slice(0, 10)
// Show banner only if there are in-progress batch sessions and it hasn't been dismissed
const hasActiveBatch = recentSessions.some(s => s.started_at && !s.completed_at && s.batch_id)
return (
<div className="container mx-auto max-w-4xl space-y-6 px-4 py-6 sm:px-6 sm:py-8">
{/* Active batch banner */}
{hasActiveBatch && !bannerDismissed && (
<ActiveBatchBanner
treeId={id!}
sessions={recentSessions}
onDismiss={() => setBannerDismissed(true)}
/>
)}
{/* Header */}
<PageHeader
title={tree.name}
@@ -122,10 +153,13 @@ export default function MaintenanceFlowDetailPage() {
Edit Flow
</button>
<button
onClick={() => navigate(`/flows/${id}/navigate`)}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
onClick={handleRun}
disabled={isRunning}
className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-70"
>
<Play className="h-3.5 w-3.5" />
{isRunning
? <Spinner size="sm" className="h-3.5 w-3.5 border-white border-t-transparent" />
: <Play className="h-3.5 w-3.5" />}
Run
</button>
<button
@@ -186,29 +220,87 @@ export default function MaintenanceFlowDetailPage() {
<p className="text-[0.875rem] text-muted-foreground">No runs yet. Launch a batch to get started.</p>
) : (
<div className="space-y-2">
{batches.map(([batchKey, sessions]) => {
const completed = sessions.filter(s => s.completed_at).length
const total = sessions.length
const date = sessions[0]?.started_at
{batches.map(([batchKey, batchSessions]) => {
const completed = batchSessions.filter(s => s.completed_at).length
const total = batchSessions.length
const isActive = batchSessions.some(s => s.started_at && !s.completed_at)
const date = batchSessions[0]?.started_at
const isSingleRun = !batchSessions[0]?.batch_id
// Outcome summary
const outcomeCounts = batchSessions.reduce<Record<string, number>>((acc, s) => {
if (s.outcome) acc[s.outcome] = (acc[s.outcome] ?? 0) + 1
return acc
}, {})
const outcomeParts = Object.entries(outcomeCounts)
.map(([k, v]) => `${v} ${OUTCOME_LABELS[k] ?? k}`)
// Mini progress dots (up to 8 shown)
const dotsToShow = Math.min(total, 8)
const dots = Array.from({ length: dotsToShow }, (_, i) => i < completed)
const extraDots = total > 8 ? total - 8 : 0
const handleRowClick = () => {
if (isSingleRun && batchSessions[0]) {
navigate(`/sessions/${batchSessions[0].id}`)
} else {
navigate(`/flows/${id}/batches/${batchKey}`)
}
}
return (
<div key={batchKey} className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<button
key={batchKey}
onClick={handleRowClick}
className="w-full flex items-center justify-between rounded-lg border border-border px-4 py-3 hover:bg-accent transition-colors text-left"
>
<div>
<p className="text-[0.875rem] font-medium text-foreground">
{total} target{total !== 1 ? 's' : ''}
</p>
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
<div className="flex items-center gap-2">
{isActive && (
<span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
<p className="text-[0.875rem] font-medium text-foreground">
{isSingleRun ? 'Manual run' : `${total} target${total !== 1 ? 's' : ''}`}
</p>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
{date && (
<p className="text-[0.8125rem] text-muted-foreground">
{new Date(date).toLocaleDateString()}
</p>
)}
{outcomeParts.length > 0 && (
<p className="text-[0.8125rem] text-muted-foreground">
· {outcomeParts.join(' · ')}
</p>
)}
</div>
</div>
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
completed === total ? "text-emerald-400" : "text-amber-400"
)}>
{completed}/{total} complete
</span>
</div>
<div className="flex items-center gap-2">
{!isSingleRun && total > 1 && (
<div className="flex items-center gap-0.5">
{dots.map((done, i) => (
<span
key={i}
className={cn(
'inline-block h-2 w-2 rounded-full',
done ? 'bg-emerald-400' : 'bg-muted'
)}
/>
))}
{extraDots > 0 && (
<span className="ml-1 text-[0.6875rem] text-muted-foreground">+{extraDots}</span>
)}
</div>
)}
<span className={cn(
"font-label text-[0.75rem] uppercase tracking-wide",
isActive ? "text-amber-400" : completed === total ? "text-emerald-400" : "text-muted-foreground"
)}>
{isActive ? 'In Progress' : `${completed}/${total}`}
</span>
</div>
</button>
)
})}
</div>

View File

@@ -14,6 +14,7 @@ import { usePermissions } from '@/hooks/usePermissions'
import { toast } from '@/lib/toast'
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
import { aiBuilderApi } from '@/api/aiBuilder'
import { ForkModal } from '@/components/library/ForkModal'
interface TreeWithStats extends TreeListItem {
lastUsed?: string
@@ -36,6 +37,7 @@ export function MyTreesPage() {
const [showCreateMenu, setShowCreateMenu] = useState(false)
const [showAIBuilder, setShowAIBuilder] = useState(false)
const [aiEnabled, setAiEnabled] = useState(false)
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
useEffect(() => {
loadMyTrees()
@@ -255,6 +257,11 @@ export function MyTreesPage() {
<Wrench className="h-4 w-4 shrink-0 text-amber-400" />
)}
<h3 className="font-semibold text-foreground">{tree.name}</h3>
{tree.parent_tree_id && (
<span className="shrink-0 rounded-full bg-violet-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-violet-400">
Fork
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{tree.tree_type === 'procedural' && (
@@ -354,6 +361,14 @@ export function MyTreesPage() {
>
<Share2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setForkTarget(tree)}
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
title="Fork flow"
>
<GitBranch className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
@@ -401,6 +416,15 @@ export function MyTreesPage() {
/>
)}
{/* Fork Modal */}
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
{/* AI Flow Builder Modal */}
<AIFlowBuilderModal
isOpen={showAIBuilder}

View File

@@ -1,9 +1,12 @@
import { useEffect, useState, useRef } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { sessionsApi } from '@/api/sessions'
import type { Tree, Session, ProceduralStep, DecisionRecord } from '@/types'
import { stepsApi } from '@/api/steps'
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep } from '@/types'
import type { CustomStep } from '@/types/session'
import type { Step } from '@/types/step'
import { IntakeFormModal } from '@/components/procedural/IntakeFormModal'
import { StepChecklist } from '@/components/procedural/StepChecklist'
import { StepDetail } from '@/components/procedural/StepDetail'
@@ -16,6 +19,10 @@ import { toast } from '@/lib/toast'
import { StepFeedback } from '@/components/session/StepFeedback'
import { CSATModal } from '@/components/session/CSATModal'
import { hasBeenRated } from '@/components/session/csatUtils'
import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceContextStrip'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
interface StepState {
notes: string
@@ -23,6 +30,29 @@ interface StepState {
completedAt: string | null
}
function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
const result: RuntimeStep[] = [...baseSteps]
const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
for (const cs of sorted) {
const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id)
const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length
const runtimeCustom: CustomProceduralStep = {
id: cs.id,
type: 'procedure_step',
title: cs.step_data.title,
description: cs.step_data.content?.instructions,
content_type: 'action',
commands: cs.step_data.content?.commands?.map((c) => ({
code: c.command,
label: c.label,
})),
isCustom: true,
}
result.splice(insertAt, 0, runtimeCustom)
}
return result
}
export function ProceduralNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -43,8 +73,18 @@ export function ProceduralNavigationPage() {
const [paramsOpen, setParamsOpen] = useState(false)
const [showCsatModal, setShowCsatModal] = useState(false)
const [elapsedMinutes, setElapsedMinutes] = useState(0)
const [batchProgress, setBatchProgress] = useState<{ completed: number; total: number } | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Custom step state
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
const [showPostStepModal, setShowPostStepModal] = useState(false)
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
const [isSavingStep, setIsSavingStep] = useState(false)
// Get procedural steps from tree
const getSteps = (): ProceduralStep[] => {
if (!tree) return []
@@ -53,7 +93,7 @@ export function ProceduralNavigationPage() {
}
const steps = getSteps()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
const completedStepIds = new Set(
Array.from(stepStates.entries())
.filter(([, state]) => state.completedAt)
@@ -61,7 +101,7 @@ export function ProceduralNavigationPage() {
)
const estimatedTotalMinutes = procedureSteps.reduce(
(sum, step) => sum + (step.estimated_minutes || 0),
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
0
)
@@ -97,6 +137,19 @@ export function ProceduralNavigationPage() {
}
}, [session, isComplete])
// Fetch batch progress once when session loads (maintenance flows only)
useEffect(() => {
if (!session?.batch_id) return
sessionsApi.list({ batch_id: session.batch_id, size: 100 })
.then(data => {
if (Array.isArray(data) && data.length > 0) {
const completed = data.filter(s => s.completed_at).length
setBatchProgress({ completed, total: data.length })
}
})
.catch(() => {})
}, [session?.batch_id])
const loadTree = async (id: string) => {
setIsLoading(true)
try {
@@ -144,6 +197,8 @@ export function ProceduralNavigationPage() {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
setStepStates(initialStates)
setRuntimeSteps(allSteps)
setSessionCustomSteps([])
} catch {
toast.error('Failed to start session')
}
@@ -158,9 +213,18 @@ export function ProceduralNavigationPage() {
// Initialize step states from session decisions
const allSteps = getStepsFromTree(treeData)
// Initialize custom steps from session data
const customSteps = sessionData.custom_steps || []
setSessionCustomSteps(customSteps)
const hydrated = buildRuntimeSteps(allSteps, customSteps)
setRuntimeSteps(hydrated)
const initialStates = new Map<string, StepState>()
for (const step of allSteps) {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
for (const step of hydrated) {
if (step.type === 'procedure_step') {
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
}
}
// Hydrate completed steps from decisions
@@ -176,7 +240,7 @@ export function ProceduralNavigationPage() {
setStepStates(initialStates)
// Set current step to first incomplete step
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
const pSteps = hydrated.filter((s) => s.type === 'procedure_step')
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
} catch {
@@ -288,6 +352,112 @@ export function ProceduralNavigationPage() {
setShowCsatModal(false)
}
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
setPendingCustomStep(step)
setPendingIsFromLibrary(isFromLibrary)
setShowCustomStepModal(false)
setShowPostStepModal(true)
}
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
if (!session) return
const id = crypto.randomUUID()
const currentStep = procedureSteps[currentStepIndex]
const insertedAfterId = currentStep?.id ?? ''
const runtimeCustom: CustomProceduralStep = {
id,
type: 'procedure_step',
title: step.title,
description: step.content?.instructions,
content_type: 'action',
commands: step.content?.commands?.map((c) => ({
code: c.command,
label: c.label,
})),
isCustom: true,
}
setRuntimeSteps((prev) => {
const next = [...prev]
const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
next.splice(insertAt, 0, runtimeCustom)
return next
})
setStepStates((prev) => {
const next = new Map(prev)
next.set(id, { notes: '', verificationValue: '', completedAt: null })
return next
})
const newCustomStep: CustomStep = {
id,
inserted_after_node_id: insertedAfterId,
step_data: step,
timestamp: new Date().toISOString(),
}
const newCustomSteps = [...sessionCustomSteps, newCustomStep]
setSessionCustomSteps(newCustomSteps)
try {
await sessionsApi.update(session.id, { custom_steps: newCustomSteps })
} catch {
toast.error('Failed to save custom step')
}
setCurrentStepIndex(prev => prev + 1)
}
const handleSaveForLater = async () => {
if (!pendingCustomStep || pendingIsFromLibrary) return
setIsSavingStep(true)
try {
await stepsApi.create({
title: pendingCustomStep.title,
step_type: pendingCustomStep.step_type,
content: pendingCustomStep.content,
visibility: 'private',
})
toast.success('Step saved to library')
} catch {
toast.error('Failed to save step')
} finally {
setIsSavingStep(false)
setShowPostStepModal(false)
setPendingCustomStep(null)
}
}
const handleUseNow = async () => {
if (!pendingCustomStep) return
setShowPostStepModal(false)
await handleInsertCustomStep(pendingCustomStep)
setPendingCustomStep(null)
}
const handleBoth = async () => {
if (!pendingCustomStep || pendingIsFromLibrary) return
setIsSavingStep(true)
try {
await stepsApi.create({
title: pendingCustomStep.title,
step_type: pendingCustomStep.step_type,
content: pendingCustomStep.content,
visibility: 'private',
})
} catch {
toast.error('Failed to save step to library')
} finally {
setIsSavingStep(false)
}
setShowPostStepModal(false)
await handleInsertCustomStep(pendingCustomStep)
setPendingCustomStep(null)
}
// Loading state
if (isLoading) {
return (
@@ -382,6 +552,16 @@ export function ProceduralNavigationPage() {
</div>
</div>
{/* Maintenance context strip */}
{tree?.tree_type === 'maintenance' && session && (
<MaintenanceContextStrip
treeId={treeId!}
targetLabel={session.target_label}
batchId={session.batch_id}
batchProgress={batchProgress}
/>
)}
{/* Main content */}
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* Left sidebar - step checklist */}
@@ -394,7 +574,7 @@ export function ProceduralNavigationPage() {
{sidebarOpen && (
<>
<StepChecklist
steps={steps}
steps={runtimeSteps}
currentStepIndex={currentStepIndex}
completedStepIds={completedStepIds}
onStepClick={setCurrentStepIndex}
@@ -433,7 +613,21 @@ export function ProceduralNavigationPage() {
isLast={currentStepIndex === procedureSteps.length - 1}
/>
)}
{session && currentStep && (
{/* Add custom step — only on current active incomplete non-custom step */}
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
<div className="mt-4">
<button
onClick={() => setShowCustomStepModal(true)}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus className="h-4 w-4" />
Add Step
</button>
</div>
)}
{session && currentStep && !('isCustom' in currentStep && currentStep.isCustom) && (
<div className="mt-3 flex justify-end">
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
</div>
@@ -489,6 +683,27 @@ export function ProceduralNavigationPage() {
</div>
</div>
)}
{/* Custom Step Modal */}
<CustomStepModal
isOpen={showCustomStepModal}
onClose={() => setShowCustomStepModal(false)}
onInsertStep={handleStepCreated}
/>
{/* Post Step Action Modal */}
{pendingCustomStep && (
<PostStepActionModal
isOpen={showPostStepModal}
onClose={() => { setShowPostStepModal(false); setPendingCustomStep(null) }}
step={pendingCustomStep}
onSaveForLater={handleSaveForLater}
onUseNow={handleUseNow}
onBoth={handleBoth}
isFromLibrary={pendingIsFromLibrary}
isSaving={isSavingStep}
/>
)}
</div>
)
}

View File

@@ -1,15 +1,17 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Copy, Check, Eye, Save, Share2 } from 'lucide-react'
import { Copy, Check, Eye, Save, Share2, CheckCircle2, AlertTriangle, ArrowUpRight, HelpCircle, Flag } from 'lucide-react'
import { sessionsApi } from '@/api/sessions'
import { stepsApi } from '@/api/steps'
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
import { SessionOutcomeModal } from '@/components/session/SessionOutcomeModal'
import { SessionTimeline } from '@/components/session/SessionTimeline'
import { StepRatingModal } from '@/components/session/StepRatingModal'
import { ActionMenu } from '@/components/common/ActionMenu'
import type { MenuAction } from '@/components/common/ActionMenu'
import type { SessionOutcome } from '@/types'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
@@ -36,6 +38,8 @@ export function SessionDetailPage() {
const [isSavingRatings, setIsSavingRatings] = useState(false)
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
const [showShareModal, setShowShareModal] = useState(false)
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
const [isCompleting, setIsCompleting] = useState(false)
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
@@ -227,6 +231,21 @@ export function SessionDetailPage() {
}
}
const handleCompleteSession = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => {
if (!session) return
setIsCompleting(true)
try {
const updated = await sessionsApi.complete(session.id, data)
setSession(updated)
setShowOutcomeModal(false)
toast.success('Session completed')
} catch {
toast.error('Failed to complete session')
} finally {
setIsCompleting(false)
}
}
const getDefaultTreeName = () => {
if (!session) return ''
const treeName = session.tree_snapshot?.name || 'Tree'
@@ -310,159 +329,157 @@ export function SessionDetailPage() {
)
}
// Outcome display config
const OUTCOME_CONFIG: Record<string, { icon: React.ReactNode; color: string; bg: string; border: string }> = {
resolved: { icon: <CheckCircle2 className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10', border: 'border-emerald-500/20' },
workaround: { icon: <AlertTriangle className="h-5 w-5" />, color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20' },
escalated: { icon: <ArrowUpRight className="h-5 w-5" />, color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20' },
unresolved: { icon: <HelpCircle className="h-5 w-5" />, color: 'text-muted-foreground', bg: 'bg-muted', border: 'border-border' },
}
const outcomeConfig = session.outcome ? OUTCOME_CONFIG[session.outcome] : null
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<button
onClick={() => navigate('/sessions')}
className="mb-2 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
<span
className={cn(
'flex items-center gap-1',
session.completed_at ? 'text-emerald-400' : 'text-yellow-400'
{/* Back nav */}
<button
onClick={() => navigate('/sessions')}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
Back to sessions
</button>
{/* Page title row */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">
{session.ticket_number || 'Session Details'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{session.tree_snapshot?.name}
{session.client_name && <> · Client: {session.client_name}</>}
{' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
<ActionMenu
actions={[
{ label: 'Share', icon: Share2, onClick: () => setShowShareModal(true) },
...(session.completed_at ? [{ label: 'Save as Tree', icon: Save, onClick: () => setShowSaveAsTreeModal(true) }] as MenuAction[] : []),
]}
/>
</div>
{/* Session summary card */}
{session.completed_at && outcomeConfig ? (
<div className={cn('mb-6 rounded-xl border p-5', outcomeConfig.border, outcomeConfig.bg)}>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<span className={outcomeConfig.color}>{outcomeConfig.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className={cn('text-base font-semibold', outcomeConfig.color)}>{outcomeLabel}</span>
<span className="text-sm text-muted-foreground">· {getTotalDuration()}</span>
</div>
{session.outcome_notes && (
<p className="mt-1 text-sm text-muted-foreground">{session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">Next Steps</span>
<p className="mt-0.5 text-sm text-muted-foreground whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
>
<span
className={cn(
'h-2.5 w-2.5 rounded-full',
session.completed_at ? 'bg-green-500' : 'bg-yellow-500'
)}
/>
{session.completed_at ? 'Completed' : 'In Progress'}
</span>
{session.client_name && <span>Client: {session.client_name}</span>}
{session.completed_at && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-foreground">
Duration: {getTotalDuration()}
</span>
)}
{outcomeLabel && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-foreground">
Outcome: {outcomeLabel}
</span>
)}
</div>
{session.outcome_notes && (
<p className="mt-2 text-sm text-muted-foreground">Outcome Notes: {session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="text-sm text-muted-foreground">Next Steps:</span>
<p className="mt-0.5 text-sm text-muted-foreground whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<ActionMenu
actions={[
{
label: 'Share',
icon: Share2,
onClick: () => setShowShareModal(true),
},
...(session.completed_at ? [{
label: 'Save as Tree',
icon: Save,
onClick: () => setShowSaveAsTreeModal(true),
}] as MenuAction[] : []),
]}
/>
{/* Copy for Ticket */}
</div>
{/* Primary action: Copy for Ticket */}
<button
onClick={handleCopyForTicket}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90'
)}
className="flex shrink-0 items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
{/* Export Controls */}
<div className="flex items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
aria-label="Export format"
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground sm:w-auto',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className={cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>
Through step {idx + 1}
</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className={cn(
'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className={cn(
'rounded-md border border-border bg-card p-2 text-muted-foreground',
'hover:bg-accent hover:text-foreground disabled:opacity-50'
)}
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<button
onClick={handlePreview}
disabled={isExporting}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
'hover:opacity-90 disabled:opacity-50'
)}
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</button>
</div>
</div>
</div>
) : !session.completed_at ? (
/* In-progress banner */
<div className="mb-6 flex items-center justify-between gap-4 rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4">
<div className="flex items-center gap-3">
<Flag className="h-4 w-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">Session in progress</p>
<p className="text-xs text-muted-foreground">Set an outcome to finalize this session and generate documentation.</p>
</div>
</div>
<button
onClick={() => setShowOutcomeModal(true)}
className="shrink-0 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
Complete Session
</button>
</div>
) : null}
{/* Export toolbar (secondary) */}
<div className="mb-6 flex flex-wrap items-center gap-2">
<select
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
aria-label="Export format"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>Through step {idx + 1}</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
title="Copy to clipboard"
className="rounded-md border border-border bg-card p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
<button
onClick={handlePreview}
disabled={isExporting}
className="flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<Eye className="h-4 w-4" />
{isExporting ? 'Loading...' : 'Preview'}
</button>
{/* Copy for ticket (secondary position when session is complete) */}
{session.completed_at && (
<button
onClick={handleCopyForTicket}
className="flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
{copiedPsa ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
)}
</div>
{/* Timeline / Step Checklist */}
@@ -513,6 +530,14 @@ export function SessionDetailPage() {
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
/>
{/* Complete Session Modal (in-progress sessions) */}
<SessionOutcomeModal
isOpen={showOutcomeModal}
onClose={() => setShowOutcomeModal(false)}
onSubmit={handleCompleteSession}
isSubmitting={isCompleting}
/>
</div>
)
}

View File

@@ -1,25 +1,179 @@
import { Bookmark } from 'lucide-react'
import { useState } from 'react'
import { Bookmark, Trash2 } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { stepsApi } from '@/api/steps'
import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser'
import { StepFormModal } from '@/components/step-library/StepFormModal'
import type { Step, StepListItem } from '@/types/step'
export default function StepLibraryPage() {
const user = useAuthStore((s) => s.user)
const { canCreateSteps } = usePermissions()
// Create/edit modal state
const [createOpen, setCreateOpen] = useState(false)
const [editingStep, setEditingStep] = useState<Step | null>(null)
// Delete confirmation state
const [deletingStep, setDeletingStep] = useState<StepListItem | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
// Toast for "Save to My Library"
const [saveToast, setSaveToast] = useState<string | null>(null)
// Increment to trigger StepLibraryBrowser reload
const [refreshKey, setRefreshKey] = useState(0)
const refresh = () => setRefreshKey(k => k + 1)
// Fetch full step before opening edit modal (StepListItem lacks content)
const handleEdit = async (step: StepListItem) => {
try {
const full = await stepsApi.get(step.id)
setEditingStep(full)
} catch (err) {
console.error('Failed to load step for edit:', err)
}
}
const handleDeleteRequest = (step: StepListItem) => {
setDeletingStep(step)
}
const handleDeleteConfirm = async () => {
if (!deletingStep) return
setIsDeleting(true)
setDeleteError(null)
try {
await stepsApi.delete(deletingStep.id)
setDeletingStep(null)
refresh()
} catch (err) {
console.error('Failed to delete step:', err)
setDeleteError('Failed to delete step. Please try again.')
} finally {
setIsDeleting(false)
}
}
const handleSave = async (step: StepListItem) => {
try {
const full = await stepsApi.get(step.id)
await stepsApi.create({
title: full.title,
step_type: full.step_type,
content: full.content,
visibility: 'private',
category_id: full.category_id,
tags: full.tags,
})
setSaveToast(`"${full.title}" saved to My Steps`)
setTimeout(() => setSaveToast(null), 3000)
refresh()
} catch (err) {
console.error('Failed to save step:', err)
}
}
const handleFormSuccess = (_step: Step) => {
setCreateOpen(false)
setEditingStep(null)
refresh()
}
const handleCloseModal = () => {
setCreateOpen(false)
setEditingStep(null)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
<div className="flex h-full flex-col">
{/* Page Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<span title="Step Library"><Bookmark className="h-8 w-8 text-muted-foreground" /></span>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Step Library</h1>
<span title="Step Library">
<Bookmark className="h-6 w-6 text-muted-foreground" />
</span>
<div>
<h1 className="text-xl font-bold font-heading text-foreground">Step Library</h1>
<p className="text-sm text-muted-foreground">Reusable steps you can insert into any flow</p>
</div>
</div>
<p className="mt-2 text-muted-foreground">Reusable steps for your flows coming soon.</p>
{canCreateSteps && (
<button
onClick={() => setCreateOpen(true)}
className="rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
+ Create Step
</button>
)}
</div>
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Bookmark className="h-8 w-8 text-primary" />
</div>
<h2 className="text-lg font-semibold text-foreground mb-2">Coming Soon</h2>
<p className="max-w-md text-sm text-muted-foreground">
The Step Library will let you create, share, and reuse common troubleshooting steps across all your flows.
</p>
{/* Browser fills remaining height */}
<div className="flex-1 overflow-hidden">
<StepLibraryBrowser
onEdit={(step) => { handleEdit(step) }}
onDelete={handleDeleteRequest}
onSave={handleSave}
currentUserId={user?.id}
refreshKey={refreshKey}
showCreateButton={false}
/>
</div>
{/* Create / Edit Modal */}
<StepFormModal
isOpen={createOpen || !!editingStep}
onClose={handleCloseModal}
onSuccess={handleFormSuccess}
editingStep={editingStep}
/>
{/* Delete Confirmation Dialog */}
{deletingStep && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="w-full max-w-sm rounded-xl bg-card border border-border p-6 shadow-lg">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-red-400/10 p-2">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<h2 className="text-base font-semibold text-foreground">Delete Step</h2>
</div>
<p className="mb-2 text-sm text-muted-foreground">
Are you sure you want to delete{' '}
<span className="font-medium text-foreground">"{deletingStep.title}"</span>?
</p>
<p className="mb-6 text-xs text-muted-foreground">This cannot be undone.</p>
{deleteError && (
<p className="mb-4 text-sm text-red-400">{deleteError}</p>
)}
<div className="flex gap-2">
<button
onClick={() => { setDeletingStep(null); setDeleteError(null) }}
disabled={isDeleting}
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
{/* Save Toast */}
{saveToast && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-foreground shadow-lg">
{saveToast}
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { foldersApi } from '@/api/folders'
import { sessionsApi } from '@/api/sessions'
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
import { FolderEditModal } from '@/components/library/FolderEditModal'
import { ForkModal } from '@/components/library/ForkModal'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { TreeGridView } from '@/components/library/TreeGridView'
import { TreeListView } from '@/components/library/TreeListView'
@@ -72,8 +73,8 @@ export function TreeLibraryPage() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Fork state
const [isForkingTree, setIsForkingTree] = useState(false)
// Fork modal state
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
// AI builder state
const [showAIBuilder, setShowAIBuilder] = useState(false)
@@ -244,19 +245,9 @@ export function TreeLibraryPage() {
}
}
const handleForkTree = async (treeId: string) => {
if (isForkingTree) return
setIsForkingTree(true)
try {
await treesApi.fork(treeId)
toast.success('Flow forked successfully')
navigate('/my-trees')
} catch (err) {
console.error('Failed to fork flow:', err)
toast.error('Failed to fork flow')
} finally {
setIsForkingTree(false)
}
const handleForkTree = (treeId: string) => {
const tree = trees.find((t) => t.id === treeId)
if (tree) setForkTarget(tree)
}
const hasActiveFilters =
@@ -572,6 +563,14 @@ export function TreeLibraryPage() {
onClose={() => setShowAIBuilder(false)}
/>
)}
{forkTarget && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
</div>
)
}

View File

@@ -25,6 +25,7 @@ const TreeEditorPage = lazy(() => import('@/pages/TreeEditorPage'))
const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
const MaintenanceFlowDetailPage = lazy(() => import('@/pages/MaintenanceFlowDetailPage'))
const BatchStatusPage = lazy(() => import('@/pages/BatchStatusPage'))
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
@@ -180,6 +181,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'flows/:id/batches/:batchId',
element: (
<Suspense fallback={<PageLoader />}>
<BatchStatusPage />
</Suspense>
),
},
{
path: 'trees/:id/navigate',
element: (

View File

@@ -10,6 +10,7 @@ export interface StepContent {
instructions: string
help_text?: string
commands?: StepCommand[]
group_label?: string
}
export interface Step {
@@ -28,6 +29,8 @@ export interface Step {
helpful_no: number
is_featured: boolean
is_verified: boolean
is_flow_synced: boolean
source_tree_name: string | null
created_by: string
author_name?: string
created_at: string
@@ -46,6 +49,8 @@ export interface StepListItem {
rating_average: number
rating_count: number
is_featured: boolean
is_flow_synced: boolean
source_tree_name: string | null
created_by: string
author_name?: string
created_at: string

View File

@@ -121,8 +121,21 @@ export interface ProceduralStep {
notes_enabled?: boolean
section_header?: string
reference_url?: string
library_visibility?: 'team' | 'public'
}
export interface CustomProceduralStep {
id: string
type: 'procedure_step'
title: string
description?: string
content_type: 'action'
commands?: CommandBlock[]
isCustom: true
}
export type RuntimeStep = ProceduralStep | CustomProceduralStep
export interface ProceduralTreeStructure {
steps: ProceduralStep[]
}
@@ -130,6 +143,16 @@ export interface ProceduralTreeStructure {
// API response types
export type TreeStatus = 'draft' | 'published'
/** Fork lineage metadata. Only present (non-null) on trees that are forks (fork_depth > 0). */
export interface ForkInfo {
parent_tree_id: string | null
root_tree_id: string | null
fork_reason: string | null
fork_depth: number
parent_updated_at: string | null
has_parent_updates: boolean
}
export interface Tree {
id: string
name: string
@@ -152,6 +175,7 @@ export interface Tree {
created_at: string
updated_at: string
usage_count: number
fork_info: ForkInfo | null
}
export interface TreeListItem {