refactor: tech debt reduction - extract hooks, deduplicate helpers, update deps, add CI

- Extract useCustomStepFlow hook from TreeNavigationPage (1040 → 759 lines)
- Create core/filters.py with shared tree/step visibility filters
- Create services/export_service.py from session export logic
- Add GitHub Actions CI/CD pipeline (pytest + lint + build)
- Add GIN index migration for full-text search on trees
- Update FastAPI 0.128.5, Pydantic 2.12.5, SQLAlchemy 2.0.46, +5 more
- Fix regex → pattern deprecation in Query() params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-08 08:14:22 -05:00
parent f4eb3fe186
commit b97596d286
12 changed files with 786 additions and 557 deletions

73
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: patherly_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test
DATABASE_URL_SYNC: postgresql://postgres:postgres@localhost:5432/patherly_test
SECRET_KEY: ci-test-secret-key-not-for-production
DEBUG: "true"
APP_NAME: ResolutionFlow
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: |
backend/requirements.txt
backend/requirements-dev.txt
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run tests
run: cd backend && python -m pytest --override-ini="addopts="
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: cd frontend && npm ci
- name: Lint
run: cd frontend && npm run lint
- name: Build
run: cd frontend && npm run build

View File

@@ -147,6 +147,13 @@ When adding new frontend pages or components, use "ResolutionFlow" for any user-
- **Global Thin Scrollbar Styling:** - **Global Thin Scrollbar Styling:**
- 6px thin scrollbars site-wide (Firefox `scrollbar-width: thin` + WebKit pseudo-elements) - 6px thin scrollbars site-wide (Firefox `scrollbar-width: thin` + WebKit pseudo-elements)
- Theme-aware colors using CSS variables (`--border`, `--muted-foreground`) - Theme-aware colors using CSS variables (`--border`, `--muted-foreground`)
- **Admin Panel (Feb 2026):**
- Full admin panel at `/admin/*` with 8 pages (dashboard, users, invite codes, audit logs, plan limits, feature flags, settings, categories)
- Super admin access: requires `is_super_admin=true` on User model
- Admin API endpoints: `/api/v1/admin/*` (all require `require_admin` dependency)
- Utility script: `backend/make_superadmin_simple.py list|<email>` - promote users to super admin
- ActionMenu component: uses React Portal to avoid overflow clipping in tables
- **API Path Gotcha:** Frontend `apiClient` baseURL is `http://localhost:8000/api/v1` — all API calls should use relative paths WITHOUT `/api/v1/` prefix (e.g., `/admin/users` not `/api/v1/admin/users`)
### What's In Progress ### What's In Progress

View File

@@ -0,0 +1,32 @@
"""add full-text search GIN index on trees
Revision ID: 027
Revises: 026
Create Date: 2026-02-08
Adds a GIN index for full-text search on the trees table,
indexing the name and description columns using English text search.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '027'
down_revision: Union[str, None] = '026'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE INDEX idx_trees_fts ON trees USING GIN ("
"to_tsvector('english', COALESCE(name, '') || ' ' || COALESCE(description, ''))"
")"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_trees_fts")

View File

@@ -1,4 +1,3 @@
import html
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Annotated, Optional from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
@@ -14,6 +13,7 @@ from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
from app.api.deps import get_current_active_user from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export
router = APIRouter(prefix="/sessions", tags=["sessions"]) router = APIRouter(prefix="/sessions", tags=["sessions"])
@@ -283,13 +283,13 @@ async def export_session(
# Generate export based on format # Generate export based on format
if export_options.format == "markdown": if export_options.format == "markdown":
content = _generate_markdown_export(session, export_options) content = generate_markdown_export(session, export_options)
media_type = "text/markdown" media_type = "text/markdown"
elif export_options.format == "html": elif export_options.format == "html":
content = _generate_html_export(session, export_options) content = generate_html_export(session, export_options)
media_type = "text/html" media_type = "text/html"
else: # text else: # text
content = _generate_text_export(session, export_options) content = generate_text_export(session, export_options)
media_type = "text/plain" media_type = "text/plain"
# Mark as exported # Mark as exported
@@ -299,158 +299,6 @@ async def export_session(
return PlainTextResponse(content=content, media_type=media_type) return PlainTextResponse(content=content, media_type=media_type)
def _generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(f"# {tree_name}")
lines.append("")
if session.ticket_number:
lines.append(f"**Ticket:** {session.ticket_number}")
if session.client_name:
lines.append(f"**Client:** {session.client_name}")
if options.include_timestamps:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("---")
lines.append("")
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("## Evidence / Reference")
lines.append("")
lines.append(scratchpad)
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Troubleshooting Steps")
lines.append("")
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
return "\n".join(lines)
def _generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(tree_name)
lines.append("=" * len(tree_name))
if session.ticket_number:
lines.append(f"Ticket: {session.ticket_number}")
if session.client_name:
lines.append(f"Client: {session.client_name}")
if options.include_timestamps:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("EVIDENCE / REFERENCE")
lines.append("-" * 20)
lines.append(scratchpad)
lines.append("")
lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20)
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
return "\n".join(lines)
def _generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
f'<title>{tree_name}</title>',
'<style>',
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
'h1 { color: #333; }',
'.meta { color: #666; margin-bottom: 20px; }',
'.step { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }',
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'</style>',
'</head>', '<body>']
if options.include_tree_info:
html_parts.append(f'<h1>{tree_name}</h1>')
html_parts.append('<div class="meta">')
if session.ticket_number:
html_parts.append(f'<p><strong>Ticket:</strong> {html.escape(session.ticket_number)}</p>')
if session.client_name:
html_parts.append(f'<p><strong>Client:</strong> {html.escape(session.client_name)}</p>')
if options.include_timestamps:
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html_parts.append('</div>')
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
html_parts.append('<h2>Evidence / Reference</h2>')
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
html_parts.append('<h2>Troubleshooting Steps</h2>')
for i, decision in enumerate(session.decisions, 1):
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", ""))
html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
if answer:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)
# --- Save Session as Tree --- # --- Save Session as Tree ---

View File

@@ -3,12 +3,13 @@ from typing import Optional
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, or_, and_, func, desc, Integer, case from sqlalchemy import select, func, desc, Integer, case
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
from app.api.deps import get_current_active_user, require_engineer_or_admin from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_view_step, can_edit_step from app.core.permissions import can_view_step, can_edit_step
from app.core.filters import build_step_visibility_filter
from app.models.user import User from app.models.user import User
from app.models.step_library import StepLibrary, StepRating from app.models.step_library import StepLibrary, StepRating
from app.models.step_category import StepCategory from app.models.step_category import StepCategory
@@ -53,29 +54,15 @@ async def get_step_or_404(
return step return step
def build_visibility_filter(user: User):
"""Build SQLAlchemy filter for step visibility based on user."""
if user.account_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == user.account_id),
StepLibrary.created_by == user.id # Own private steps
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == user.id
)
@router.get("", response_model=list[StepLibraryListResponse]) @router.get("", response_model=list[StepLibraryListResponse])
async def list_steps( async def list_steps(
visibility: Optional[str] = Query(None, regex="^(private|team|public)$"), visibility: Optional[str] = Query(None, pattern="^(private|team|public)$"),
category_id: Optional[UUID] = None, category_id: Optional[UUID] = None,
tags: Optional[list[str]] = Query(None), tags: Optional[list[str]] = Query(None),
min_rating: Optional[float] = Query(None, ge=0, le=5), min_rating: Optional[float] = Query(None, ge=0, le=5),
step_type: Optional[str] = Query(None, regex="^(decision|action|solution)$"), step_type: Optional[str] = Query(None, pattern="^(decision|action|solution)$"),
sort_by: str = Query("recent", regex="^(recent|popular|highest_rated|most_used)$"), sort_by: str = Query("recent", pattern="^(recent|popular|highest_rated|most_used)$"),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -84,7 +71,7 @@ async def list_steps(
"""List steps with filters and pagination.""" """List steps with filters and pagination."""
query = select(StepLibrary).where( query = select(StepLibrary).where(
StepLibrary.is_active == True, StepLibrary.is_active == True,
build_visibility_filter(current_user) build_step_visibility_filter(current_user)
) )
# Apply filters # Apply filters
@@ -165,7 +152,7 @@ async def search_steps(
query = select(StepLibrary).where( query = select(StepLibrary).where(
StepLibrary.is_active == True, StepLibrary.is_active == True,
build_visibility_filter(current_user), build_step_visibility_filter(current_user),
func.to_tsvector('english', StepLibrary.title).match(search_query) func.to_tsvector('english', StepLibrary.title).match(search_query)
).order_by(desc(StepLibrary.rating_average)).limit(limit) ).order_by(desc(StepLibrary.rating_average)).limit(limit)
@@ -218,7 +205,7 @@ async def get_popular_tags(
func.count().label('count') func.count().label('count')
).where( ).where(
StepLibrary.is_active == True, StepLibrary.is_active == True,
build_visibility_filter(current_user) build_step_visibility_filter(current_user)
).group_by( ).group_by(
func.unnest(StepLibrary.tags) func.unnest(StepLibrary.tags)
).order_by( ).order_by(

View File

@@ -4,7 +4,7 @@ from uuid import UUID
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, true as sa_true, update from sqlalchemy import select, func, or_, update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.core.database import get_db from app.core.database import get_db
@@ -21,6 +21,7 @@ from app.schemas.tree import (
) )
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
from app.core.permissions import can_edit_tree, can_access_tree 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.subscriptions import check_tree_limit
from app.core.audit import log_audit from app.core.audit import log_audit
from app.core.config import settings from app.core.config import settings
@@ -29,28 +30,6 @@ from app.core.tree_validation import can_publish_tree
router = APIRouter(prefix="/trees", tags=["trees"]) router = APIRouter(prefix="/trees", tags=["trees"])
def build_tree_access_filter(current_user: User):
"""Build the access filter for trees based on user permissions.
Returns trees that are:
- All trees (for super admins)
- Default/system trees (visible to all)
- Public trees
- User's own trees
- Trees from user's team
"""
if current_user.is_super_admin:
return sa_true()
conditions = [
Tree.is_default == True,
Tree.is_public == True,
Tree.author_id == current_user.id,
]
if current_user.account_id:
conditions.append(Tree.account_id == current_user.account_id)
return or_(*conditions)
def build_tree_response(tree: Tree) -> TreeListResponse: def build_tree_response(tree: Tree) -> TreeListResponse:
"""Build TreeListResponse with category_info and tags.""" """Build TreeListResponse with category_info and tags."""
category_info = None category_info = None

View File

@@ -0,0 +1,60 @@
"""
Centralized query filters for ResolutionFlow.
Provides reusable SQLAlchemy filter builders for tree access control
and step visibility, used across multiple endpoint modules.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import or_, and_, true as sa_true
if TYPE_CHECKING:
from app.models.user import User
def build_tree_access_filter(current_user: User):
"""Build the access filter for trees based on user permissions.
Returns trees that are:
- All trees (for super admins)
- Default/system trees (visible to all)
- Public trees
- User's own trees
- Trees from user's account
"""
from app.models.tree import Tree
if current_user.is_super_admin:
return sa_true()
conditions = [
Tree.is_default == True,
Tree.is_public == True,
Tree.author_id == current_user.id,
]
if current_user.account_id:
conditions.append(Tree.account_id == current_user.account_id)
return or_(*conditions)
def build_step_visibility_filter(current_user: User):
"""Build SQLAlchemy filter for step visibility based on user.
Returns steps that are:
- Public steps (visible to all)
- Team steps (visible to same account members)
- User's own private steps
"""
from app.models.step_library import StepLibrary
if current_user.account_id:
return or_(
StepLibrary.visibility == 'public',
and_(StepLibrary.visibility == 'team', StepLibrary.account_id == current_user.account_id),
StepLibrary.created_by == current_user.id # Own private steps
)
else:
return or_(
StepLibrary.visibility == 'public',
StepLibrary.created_by == current_user.id
)

View File

View File

@@ -0,0 +1,162 @@
"""
Session export generators for ResolutionFlow.
Provides markdown, plain text, and HTML export formatters
for troubleshooting sessions.
"""
import html
from app.models.session import Session
from app.schemas.session import SessionExport
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(f"# {tree_name}")
lines.append("")
if session.ticket_number:
lines.append(f"**Ticket:** {session.ticket_number}")
if session.client_name:
lines.append(f"**Client:** {session.client_name}")
if options.include_timestamps:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
lines.append("---")
lines.append("")
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("## Evidence / Reference")
lines.append("")
lines.append(scratchpad)
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Troubleshooting Steps")
lines.append("")
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
return "\n".join(lines)
def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
lines.append(tree_name)
lines.append("=" * len(tree_name))
if session.ticket_number:
lines.append(f"Ticket: {session.ticket_number}")
if session.client_name:
lines.append(f"Client: {session.client_name}")
if options.include_timestamps:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append("")
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("EVIDENCE / REFERENCE")
lines.append("-" * 20)
lines.append(scratchpad)
lines.append("")
lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20)
for i, decision in enumerate(session.decisions, 1):
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
return "\n".join(lines)
def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
f'<title>{tree_name}</title>',
'<style>',
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
'h1 { color: #333; }',
'.meta { color: #666; margin-bottom: 20px; }',
'.step { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }',
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'</style>',
'</head>', '<body>']
if options.include_tree_info:
html_parts.append(f'<h1>{tree_name}</h1>')
html_parts.append('<div class="meta">')
if session.ticket_number:
html_parts.append(f'<p><strong>Ticket:</strong> {html.escape(session.ticket_number)}</p>')
if session.client_name:
html_parts.append(f'<p><strong>Client:</strong> {html.escape(session.client_name)}</p>')
if options.include_timestamps:
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html_parts.append('</div>')
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
html_parts.append('<h2>Evidence / Reference</h2>')
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
html_parts.append('<h2>Troubleshooting Steps</h2>')
for i, decision in enumerate(session.decisions, 1):
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", ""))
html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
if answer:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)

View File

@@ -1,22 +1,22 @@
# FastAPI and server # FastAPI and server
fastapi==0.109.2 fastapi==0.128.5
uvicorn[standard]==0.27.1 uvicorn[standard]==0.40.0
# Database # Database
sqlalchemy==2.0.25 sqlalchemy==2.0.46
asyncpg==0.29.0 asyncpg==0.31.0
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
alembic==1.13.1 alembic==1.18.3
# Authentication # Authentication
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.1.2 bcrypt==4.1.2
python-multipart==0.0.9 python-multipart==0.0.22
# Validation and settings # Validation and settings
pydantic==2.6.1 pydantic==2.12.5
pydantic-settings==2.1.0 pydantic-settings==2.12.0
email-validator==2.1.0 email-validator==2.1.0
# Rate Limiting # Rate Limiting

View File

@@ -0,0 +1,362 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { sessionsApi, stepsApi } from '@/api'
import { treesApi } from '@/api'
import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
import type { DescendantNode } from '@/components/session'
interface UseCustomStepFlowParams {
tree: Tree | null
session: Session | null
currentNodeId: string
pathTaken: string[]
decisions: DecisionRecord[]
notes: string
findNode: (nodeId: string, structure?: TreeStructure) => TreeStructure | null
setCurrentNodeId: (id: string) => void
setPathTaken: (path: string[]) => void
setDecisions: (decisions: DecisionRecord[]) => void
setNotes: (notes: string) => void
setIsCompleting: (completing: boolean) => void
setError: (error: string | null) => void
isCompleting: boolean
}
export function useCustomStepFlow({
tree,
session,
currentNodeId,
pathTaken,
decisions,
notes,
findNode,
setCurrentNodeId,
setPathTaken,
setDecisions,
setNotes,
setIsCompleting,
setError,
isCompleting,
}: UseCustomStepFlowParams) {
const navigate = useNavigate()
// Custom steps
const [customSteps, setCustomSteps] = useState<CustomStep[]>([])
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
// Post-step action flow
const [showPostStepModal, setShowPostStepModal] = useState(false)
const [pendingStep, setPendingStep] = useState<Step | CustomStepDraft | null>(null)
const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false)
const [isSavingStep, setIsSavingStep] = useState(false)
// Continuation flow
const [showContinuationModal, setShowContinuationModal] = useState(false)
const [branchOriginNodeId, setBranchOriginNodeId] = useState<string | null>(null)
const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState<string | null>(null)
// Custom branch mode
const [customBranchMode, setCustomBranchMode] = useState(false)
// Fork flow
const [showForkModal, setShowForkModal] = useState(false)
const findCustomStep = (nodeId: string): CustomStep | null => {
return customSteps.find(cs => cs.id === nodeId) || null
}
// Get descendant nodes two levels deep (grandchildren) from a decision node's options.
const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => {
const decisionNode = findNode(decisionNodeId, tree?.tree_structure)
if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) {
return []
}
const descendants: DescendantNode[] = []
for (const option of decisionNode.options) {
if (!option.next_node_id) continue
const childNode = findNode(option.next_node_id, tree?.tree_structure)
if (!childNode) continue
if (childNode.type === 'decision' && childNode.options) {
for (const childOption of childNode.options) {
if (!childOption.next_node_id) continue
const grandchild = findNode(childOption.next_node_id, tree?.tree_structure)
if (!grandchild) continue
descendants.push({
id: grandchild.id,
label: grandchild.question || grandchild.title || 'Untitled',
type: grandchild.type,
parentOptionLabel: `${option.label} \u2192 ${childOption.label}`
})
}
} else if (childNode.type === 'action' && childNode.next_node_id) {
const grandchild = findNode(childNode.next_node_id, tree?.tree_structure)
if (grandchild) {
descendants.push({
id: grandchild.id,
label: grandchild.question || grandchild.title || 'Untitled',
type: grandchild.type,
parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}`
})
}
}
}
return descendants
}
// Navigate back to a previously-created custom step from the decision node
const handleNavigateToCustomStep = (customStep: CustomStep) => {
const newPath = [...pathTaken, customStep.id]
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
}
// Called when CustomStepModal submits - show action modal instead of inserting directly
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
setPendingStep(step)
setPendingStepIsFromLibrary(isFromLibrary)
setShowCustomStepModal(false)
setShowPostStepModal(true)
}
const resetPendingStep = () => {
setShowPostStepModal(false)
setPendingStep(null)
setPendingStepIsFromLibrary(false)
setIsSavingStep(false)
}
// Save to library only, don't insert into session
const handleSaveForLater = async () => {
if (!pendingStep) return
setIsSavingStep(true)
try {
if (!pendingStepIsFromLibrary) {
await stepsApi.create({
title: pendingStep.title,
step_type: pendingStep.step_type,
content: pendingStep.content,
visibility: 'private',
tags: pendingStep.tags || []
})
}
resetPendingStep()
} catch (err) {
console.error('Failed to save step to library:', err)
setIsSavingStep(false)
}
}
// Insert into session and show continuation options
const handleUseNow = async () => {
if (!pendingStep || !session) return
setIsSavingStep(true)
try {
setBranchOriginNodeId(currentNodeId)
const customStep: CustomStep = {
id: crypto.randomUUID(),
inserted_after_node_id: currentNodeId,
step_data: pendingStep,
timestamp: new Date().toISOString()
}
const newDecision: DecisionRecord = {
node_id: customStep.id,
question: null,
answer: null,
action_performed: `Custom Step: ${pendingStep.title}`,
notes: pendingStep.content.instructions || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
const newCustomSteps = [...customSteps, customStep]
const newDecisions = [...decisions, newDecision]
const newPath = [...pathTaken, customStep.id]
setCustomSteps(newCustomSteps)
setDecisions(newDecisions)
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
await sessionsApi.update(session.id, {
path_taken: newPath,
decisions: newDecisions,
custom_steps: newCustomSteps
})
resetPendingStep()
setShowContinuationModal(true)
} catch (err) {
console.error('Failed to insert custom step:', err)
setIsSavingStep(false)
}
}
// Save to library AND insert into session
const handleBoth = async () => {
if (!pendingStep) return
setIsSavingStep(true)
try {
if (!pendingStepIsFromLibrary) {
await stepsApi.create({
title: pendingStep.title,
step_type: pendingStep.step_type,
content: pendingStep.content,
visibility: 'private',
tags: pendingStep.tags || []
})
}
await handleUseNow()
} catch (err) {
console.error('Failed to save and insert step:', err)
setIsSavingStep(false)
}
}
// Handle selecting a descendant node from continuation modal
const handleSelectDescendant = (nodeId: string) => {
setShowContinuationModal(false)
setBranchOriginNodeId(null)
setPendingContinuationNodeId(nodeId)
}
// Navigate to the previously-selected descendant node
const handleContinueToDescendant = async () => {
if (!pendingContinuationNodeId || !session) return
const newPath = [...pathTaken, pendingContinuationNodeId]
setPathTaken(newPath)
setCurrentNodeId(pendingContinuationNodeId)
setNotes('')
setPendingContinuationNodeId(null)
try {
await sessionsApi.update(session.id, { path_taken: newPath })
} catch (err) {
console.error('Failed to update session path:', err)
}
}
// Enter custom branch building mode
const handleBuildCustomBranch = () => {
setShowContinuationModal(false)
setCustomBranchMode(true)
}
// Complete session from custom branch mode
const handleCustomBranchComplete = async () => {
if (!session) return
setIsCompleting(true)
setError(null)
try {
const completionDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: 'Custom Branch Completed',
notes: notes || 'Issue resolved via custom troubleshooting steps',
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
await sessionsApi.update(session.id, {
decisions: [...decisions, completionDecision]
})
await sessionsApi.complete(session.id)
if (customSteps.length > 0) {
setShowForkModal(true)
} else {
navigate(`/sessions/${session.id}`)
}
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Please try again.')
} finally {
setIsCompleting(false)
}
}
// Fork tree with custom branch
const handleForkTree = async (name: string, description: string) => {
if (!tree) return
try {
const forkedTree = await treesApi.create({
name,
description,
tree_structure: tree.tree_structure,
is_public: false
})
navigate(`/trees/${forkedTree.id}/edit`)
} catch (err) {
console.error('Failed to fork tree:', err)
throw err
}
}
// Skip forking and go to session detail
const handleSkipFork = () => {
setShowForkModal(false)
navigate(`/sessions/${session!.id}`)
}
// Initialize custom steps when resuming a session
const initCustomSteps = (steps: CustomStep[]) => {
setCustomSteps(steps)
}
return {
// State
customSteps,
showCustomStepModal,
showPostStepModal,
pendingStep,
pendingStepIsFromLibrary,
isSavingStep,
showContinuationModal,
branchOriginNodeId,
pendingContinuationNodeId,
customBranchMode,
showForkModal,
// Derived
findCustomStep,
getDescendantNodes,
// Actions
setShowCustomStepModal,
setShowContinuationModal,
setShowForkModal,
initCustomSteps,
handleNavigateToCustomStep,
handleStepCreated,
resetPendingStep,
handleSaveForLater,
handleUseNow,
handleBoth,
handleSelectDescendant,
handleContinueToDescendant,
handleBuildCustomBranch,
handleCustomBranchComplete,
handleForkTree,
handleSkipFork,
isCompleting,
}
}

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useNavigate, useLocation } from 'react-router-dom' import { useParams, useNavigate, useLocation } from 'react-router-dom'
import { treesApi, sessionsApi, stepsApi } from '@/api' import { treesApi, sessionsApi } from '@/api'
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts' import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types' import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal' import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent' import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, type DescendantNode } from '@/components/session' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
import { Plus, CheckCircle, ArrowRight } from 'lucide-react' import { Plus, CheckCircle, ArrowRight } from 'lucide-react'
interface LocationState { interface LocationState {
@@ -35,33 +35,41 @@ export function TreeNavigationPage() {
const [clientName, setClientName] = useState<string>('') const [clientName, setClientName] = useState<string>('')
const [showMetadataForm, setShowMetadataForm] = useState(true) const [showMetadataForm, setShowMetadataForm] = useState(true)
// Custom steps
const [customSteps, setCustomSteps] = useState<CustomStep[]>([])
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
// Post-step action flow
const [showPostStepModal, setShowPostStepModal] = useState(false)
const [pendingStep, setPendingStep] = useState<Step | CustomStepDraft | null>(null)
const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false)
const [isSavingStep, setIsSavingStep] = useState(false)
// Continuation flow
const [showContinuationModal, setShowContinuationModal] = useState(false)
const [branchOriginNodeId, setBranchOriginNodeId] = useState<string | null>(null)
const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState<string | null>(null)
// Custom branch mode
const [customBranchMode, setCustomBranchMode] = useState(false)
// Fork flow
const [showForkModal, setShowForkModal] = useState(false)
// Scratchpad state // Scratchpad state
const [scratchpadOpen, setScratchpadOpen] = useState(() => { const [scratchpadOpen, setScratchpadOpen] = useState(() => {
return localStorage.getItem('scratchpad-collapsed') === 'false' return localStorage.getItem('scratchpad-collapsed') === 'false'
}) })
// Scratchpad save handler const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNode(nodeId, child)
if (found) return found
}
}
return null
}
// Custom step flow (creation, post-step actions, continuation, branching, forking)
const customStepFlow = useCustomStepFlow({
tree,
session,
currentNodeId,
pathTaken,
decisions,
notes,
findNode,
setCurrentNodeId,
setPathTaken,
setDecisions,
setNotes,
setIsCompleting,
setError,
isCompleting,
})
const handleScratchpadSave = async (content: string) => { const handleScratchpadSave = async (content: string) => {
if (!session) return if (!session) return
await sessionsApi.updateScratchpad(session.id, content) await sessionsApi.updateScratchpad(session.id, content)
@@ -87,7 +95,7 @@ export function TreeNavigationPage() {
setPathTaken(sessionData.path_taken) setPathTaken(sessionData.path_taken)
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root') setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
setDecisions(sessionData.decisions as DecisionRecord[]) setDecisions(sessionData.decisions as DecisionRecord[])
setCustomSteps(sessionData.custom_steps || []) customStepFlow.initCustomSteps(sessionData.custom_steps || [])
setTicketNumber(sessionData.ticket_number || '') setTicketNumber(sessionData.ticket_number || '')
setClientName(sessionData.client_name || '') setClientName(sessionData.client_name || '')
setShowMetadataForm(false) setShowMetadataForm(false)
@@ -119,70 +127,6 @@ export function TreeNavigationPage() {
} }
} }
const findNode = (nodeId: string, structure?: TreeStructure): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNode(nodeId, child)
if (found) return found
}
}
return null
}
const findCustomStep = (nodeId: string): CustomStep | null => {
return customSteps.find(cs => cs.id === nodeId) || null
}
// Get descendant nodes two levels deep (grandchildren) from a decision node's options.
// This skips the immediate children (which often mirror the option labels) and shows
// the actual next-level nodes the user would encounter further down each path.
const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => {
const decisionNode = findNode(decisionNodeId, tree?.tree_structure)
if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) {
return []
}
const descendants: DescendantNode[] = []
for (const option of decisionNode.options) {
if (!option.next_node_id) continue
const childNode = findNode(option.next_node_id, tree?.tree_structure)
if (!childNode) continue
// Go one level deeper: get the grandchildren
if (childNode.type === 'decision' && childNode.options) {
for (const childOption of childNode.options) {
if (!childOption.next_node_id) continue
const grandchild = findNode(childOption.next_node_id, tree?.tree_structure)
if (!grandchild) continue
descendants.push({
id: grandchild.id,
label: grandchild.question || grandchild.title || 'Untitled',
type: grandchild.type,
parentOptionLabel: `${option.label} \u2192 ${childOption.label}`
})
}
} else if (childNode.type === 'action' && childNode.next_node_id) {
const grandchild = findNode(childNode.next_node_id, tree?.tree_structure)
if (grandchild) {
descendants.push({
id: grandchild.id,
label: grandchild.question || grandchild.title || 'Untitled',
type: grandchild.type,
parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}`
})
}
}
// Solution children have no further descendants - skip them
}
return descendants
}
// Handler functions - defined before hook call to avoid temporal dead zone
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => { const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
if (!session || !tree) return if (!session || !tree) return
@@ -208,7 +152,6 @@ export function TreeNavigationPage() {
setCurrentNodeId(nextNodeId) setCurrentNodeId(nextNodeId)
setNotes('') setNotes('')
// Update session on backend
try { try {
await sessionsApi.update(session.id, { await sessionsApi.update(session.id, {
path_taken: newPath, path_taken: newPath,
@@ -259,7 +202,6 @@ export function TreeNavigationPage() {
setIsCompleting(true) setIsCompleting(true)
setError(null) setError(null)
try { try {
// Add final decision
const node = findNode(currentNodeId, tree.tree_structure) const node = findNode(currentNodeId, tree.tree_structure)
if (node) { if (node) {
const finalDecision: DecisionRecord = { const finalDecision: DecisionRecord = {
@@ -296,232 +238,9 @@ export function TreeNavigationPage() {
setCurrentNodeId(newPath[newPath.length - 1]) setCurrentNodeId(newPath[newPath.length - 1])
} }
// Navigate back to a previously-created custom step from the decision node
const handleNavigateToCustomStep = (customStep: CustomStep) => {
const newPath = [...pathTaken, customStep.id]
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
}
// Called when CustomStepModal submits - show action modal instead of inserting directly
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
setPendingStep(step)
setPendingStepIsFromLibrary(isFromLibrary)
setShowCustomStepModal(false)
setShowPostStepModal(true)
}
const resetPendingStep = () => {
setShowPostStepModal(false)
setPendingStep(null)
setPendingStepIsFromLibrary(false)
setIsSavingStep(false)
}
// Save to library only, don't insert into session
const handleSaveForLater = async () => {
if (!pendingStep) return
setIsSavingStep(true)
try {
if (!pendingStepIsFromLibrary) {
await stepsApi.create({
title: pendingStep.title,
step_type: pendingStep.step_type,
content: pendingStep.content,
visibility: 'private',
tags: pendingStep.tags || []
})
}
resetPendingStep()
} catch (err) {
console.error('Failed to save step to library:', err)
setIsSavingStep(false)
}
}
// Insert into session and show continuation options
const handleUseNow = async () => {
if (!pendingStep || !session) return
setIsSavingStep(true)
try {
// Remember where we branched from (for showing descendants later)
setBranchOriginNodeId(currentNodeId)
// Create custom step object
const customStep: CustomStep = {
id: crypto.randomUUID(),
inserted_after_node_id: currentNodeId,
step_data: pendingStep,
timestamp: new Date().toISOString()
}
// Record decision (so it appears in exports)
const newDecision: DecisionRecord = {
node_id: customStep.id,
question: null,
answer: null,
action_performed: `Custom Step: ${pendingStep.title}`,
notes: pendingStep.content.instructions || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
// Update state
const newCustomSteps = [...customSteps, customStep]
const newDecisions = [...decisions, newDecision]
const newPath = [...pathTaken, customStep.id]
setCustomSteps(newCustomSteps)
setDecisions(newDecisions)
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
// Persist to backend
await sessionsApi.update(session.id, {
path_taken: newPath,
decisions: newDecisions,
custom_steps: newCustomSteps
})
resetPendingStep()
// Show continuation modal
setShowContinuationModal(true)
} catch (err) {
console.error('Failed to insert custom step:', err)
setIsSavingStep(false)
}
}
// Save to library AND insert into session
const handleBoth = async () => {
if (!pendingStep) return
setIsSavingStep(true)
try {
// Save to library first
if (!pendingStepIsFromLibrary) {
await stepsApi.create({
title: pendingStep.title,
step_type: pendingStep.step_type,
content: pendingStep.content,
visibility: 'private',
tags: pendingStep.tags || []
})
}
// Then use now (this will handle the rest and reset pending step)
await handleUseNow()
} catch (err) {
console.error('Failed to save and insert step:', err)
setIsSavingStep(false)
}
}
// Handle selecting a descendant node from continuation modal.
// Stores the selection so the user can write notes on their custom step first.
const handleSelectDescendant = (nodeId: string) => {
setShowContinuationModal(false)
setBranchOriginNodeId(null)
setPendingContinuationNodeId(nodeId)
}
// Navigate to the previously-selected descendant node
const handleContinueToDescendant = async () => {
if (!pendingContinuationNodeId || !session) return
const newPath = [...pathTaken, pendingContinuationNodeId]
setPathTaken(newPath)
setCurrentNodeId(pendingContinuationNodeId)
setNotes('')
setPendingContinuationNodeId(null)
try {
await sessionsApi.update(session.id, { path_taken: newPath })
} catch (err) {
console.error('Failed to update session path:', err)
}
}
// Enter custom branch building mode
const handleBuildCustomBranch = () => {
setShowContinuationModal(false)
setCustomBranchMode(true)
}
// Complete session from custom branch mode
const handleCustomBranchComplete = async () => {
if (!session) return
setIsCompleting(true)
setError(null)
try {
// Record completion decision
const completionDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: 'Custom Branch Completed',
notes: notes || 'Issue resolved via custom troubleshooting steps',
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
await sessionsApi.update(session.id, {
decisions: [...decisions, completionDecision]
})
await sessionsApi.complete(session.id)
// Show fork modal if custom steps exist
if (customSteps.length > 0) {
setShowForkModal(true)
} else {
navigate(`/sessions/${session.id}`)
}
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Please try again.')
} finally {
setIsCompleting(false)
}
}
// Fork tree with custom branch
const handleForkTree = async (name: string, description: string) => {
if (!tree) return
try {
// Create a forked tree structure
// For now, we'll create a simple copy - the custom steps are documented in the session
const forkedTree = await treesApi.create({
name,
description,
tree_structure: tree.tree_structure, // Base structure
is_public: false
})
navigate(`/trees/${forkedTree.id}/edit`)
} catch (err) {
console.error('Failed to fork tree:', err)
throw err
}
}
// Skip forking and go to session detail
const handleSkipFork = () => {
setShowForkModal(false)
navigate(`/sessions/${session!.id}`)
}
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules) // Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null
const currentCustomStep = findCustomStep(currentNodeId) const currentCustomStep = customStepFlow.findCustomStep(currentNodeId)
const currentOptions = currentNode?.options || [] const currentOptions = currentNode?.options || []
// Keyboard shortcuts - must be called unconditionally (React hooks rules) // Keyboard shortcuts - must be called unconditionally (React hooks rules)
@@ -669,7 +388,7 @@ export function TreeNavigationPage() {
<div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm"> <div className="mb-6 flex items-center gap-2 overflow-x-auto text-sm">
{pathTaken.map((nodeId, index) => { {pathTaken.map((nodeId, index) => {
const node = findNode(nodeId, tree?.tree_structure) const node = findNode(nodeId, tree?.tree_structure)
const customStep = findCustomStep(nodeId) const customStep = customStepFlow.findCustomStep(nodeId)
const label = node?.question || node?.title || customStep?.step_data.title || nodeId const label = node?.question || node?.title || customStep?.step_data.title || nodeId
return ( return (
<span key={nodeId} className="flex items-center gap-2 whitespace-nowrap"> <span key={nodeId} className="flex items-center gap-2 whitespace-nowrap">
@@ -722,18 +441,18 @@ export function TreeNavigationPage() {
))} ))}
</div> </div>
{/* Previously-created custom steps at this node */} {/* Previously-created custom steps at this node */}
{customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && ( {customStepFlow.customSteps.filter(cs => cs.inserted_after_node_id === currentNodeId).length > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Your Custom Steps Your Custom Steps
</p> </p>
{customSteps {customStepFlow.customSteps
.filter(cs => cs.inserted_after_node_id === currentNodeId) .filter(cs => cs.inserted_after_node_id === currentNodeId)
.map(cs => ( .map(cs => (
<button <button
type="button" type="button"
key={cs.id} key={cs.id}
onClick={() => handleNavigateToCustomStep(cs)} onClick={() => customStepFlow.handleNavigateToCustomStep(cs)}
className={cn( className={cn(
'w-full rounded-md border border-purple-300 bg-purple-50 p-3 text-left transition-colors', 'w-full rounded-md border border-purple-300 bg-purple-50 p-3 text-left transition-colors',
'hover:border-purple-500 hover:bg-purple-100', 'hover:border-purple-500 hover:bg-purple-100',
@@ -752,7 +471,7 @@ export function TreeNavigationPage() {
{/* Add Custom Step Button */} {/* Add Custom Step Button */}
<button <button
onClick={() => setShowCustomStepModal(true)} onClick={() => customStepFlow.setShowCustomStepModal(true)}
className="mt-2 inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-primary hover:bg-primary/10" className="mt-2 inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-primary hover:bg-primary/10"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
@@ -802,14 +521,14 @@ export function TreeNavigationPage() {
)} )}
{/* Continue to selected descendant */} {/* Continue to selected descendant */}
{pendingContinuationNodeId && !customBranchMode && (() => { {customStepFlow.pendingContinuationNodeId && !customStepFlow.customBranchMode && (() => {
const targetNode = findNode(pendingContinuationNodeId, tree?.tree_structure) const targetNode = findNode(customStepFlow.pendingContinuationNodeId, tree?.tree_structure)
const targetLabel = targetNode?.question || targetNode?.title || 'next step' const targetLabel = targetNode?.question || targetNode?.title || 'next step'
return ( return (
<div className="mt-6 border-t border-purple-200 pt-4 dark:border-purple-700"> <div className="mt-6 border-t border-purple-200 pt-4 dark:border-purple-700">
<button <button
type="button" type="button"
onClick={handleContinueToDescendant} onClick={customStepFlow.handleContinueToDescendant}
className={cn( className={cn(
'flex w-full items-center justify-between rounded-md bg-primary px-4 py-3 text-sm font-medium text-primary-foreground', 'flex w-full items-center justify-between rounded-md bg-primary px-4 py-3 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90' 'hover:bg-primary/90'
@@ -823,14 +542,14 @@ export function TreeNavigationPage() {
})()} })()}
{/* Custom Branch Controls */} {/* Custom Branch Controls */}
{customBranchMode && ( {customStepFlow.customBranchMode && (
<div className="mt-6 border-t border-purple-200 pt-4 dark:border-purple-700"> <div className="mt-6 border-t border-purple-200 pt-4 dark:border-purple-700">
<p className="mb-3 text-sm text-amber-600 dark:text-amber-400"> <p className="mb-3 text-sm text-amber-600 dark:text-amber-400">
Building custom branch - add steps until the issue is resolved Building custom branch - add steps until the issue is resolved
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
onClick={() => setShowCustomStepModal(true)} onClick={() => customStepFlow.setShowCustomStepModal(true)}
className={cn( className={cn(
'flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium', 'flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
'bg-background hover:bg-accent hover:text-accent-foreground' 'bg-background hover:bg-accent hover:text-accent-foreground'
@@ -840,7 +559,7 @@ export function TreeNavigationPage() {
Add Another Step Add Another Step
</button> </button>
<button <button
onClick={handleCustomBranchComplete} onClick={customStepFlow.handleCustomBranchComplete}
disabled={isCompleting} disabled={isCompleting}
className={cn( className={cn(
'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white', 'flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white',
@@ -985,41 +704,41 @@ export function TreeNavigationPage() {
{/* Custom Step Modal */} {/* Custom Step Modal */}
<CustomStepModal <CustomStepModal
isOpen={showCustomStepModal} isOpen={customStepFlow.showCustomStepModal}
onClose={() => setShowCustomStepModal(false)} onClose={() => customStepFlow.setShowCustomStepModal(false)}
onInsertStep={handleStepCreated} onInsertStep={customStepFlow.handleStepCreated}
/> />
{/* Post Step Action Modal */} {/* Post Step Action Modal */}
{pendingStep && ( {customStepFlow.pendingStep && (
<PostStepActionModal <PostStepActionModal
isOpen={showPostStepModal} isOpen={customStepFlow.showPostStepModal}
onClose={resetPendingStep} onClose={customStepFlow.resetPendingStep}
step={pendingStep} step={customStepFlow.pendingStep}
onSaveForLater={handleSaveForLater} onSaveForLater={customStepFlow.handleSaveForLater}
onUseNow={handleUseNow} onUseNow={customStepFlow.handleUseNow}
onBoth={handleBoth} onBoth={customStepFlow.handleBoth}
isFromLibrary={pendingStepIsFromLibrary} isFromLibrary={customStepFlow.pendingStepIsFromLibrary}
isSaving={isSavingStep} isSaving={customStepFlow.isSavingStep}
/> />
)} )}
{/* Continuation Modal */} {/* Continuation Modal */}
<ContinuationModal <ContinuationModal
isOpen={showContinuationModal} isOpen={customStepFlow.showContinuationModal}
onClose={() => setShowContinuationModal(false)} onClose={() => customStepFlow.setShowContinuationModal(false)}
descendantNodes={branchOriginNodeId ? getDescendantNodes(branchOriginNodeId) : []} descendantNodes={customStepFlow.branchOriginNodeId ? customStepFlow.getDescendantNodes(customStepFlow.branchOriginNodeId) : []}
onSelectNode={handleSelectDescendant} onSelectNode={customStepFlow.handleSelectDescendant}
onBuildCustomBranch={handleBuildCustomBranch} onBuildCustomBranch={customStepFlow.handleBuildCustomBranch}
/> />
{/* Fork Tree Modal */} {/* Fork Tree Modal */}
<ForkTreeModal <ForkTreeModal
isOpen={showForkModal} isOpen={customStepFlow.showForkModal}
onClose={() => setShowForkModal(false)} onClose={() => customStepFlow.setShowForkModal(false)}
originalTreeName={tree?.name || 'Tree'} originalTreeName={tree?.name || 'Tree'}
onFork={handleForkTree} onFork={customStepFlow.handleForkTree}
onSkip={handleSkipFork} onSkip={customStepFlow.handleSkipFork}
/> />
</div> </div>
</div> </div>