feat(ai-session): add Phase 2 PSA integration, escalation handoff, and session management

Phase 2 of the FlowPilot-First Pivot connecting AI sessions to ConnectWise PSA:

Slice 1 — PSA Ticket Intake:
- FlowPilotEngine accepts psa_ticket intake with graceful CW API fallback
- Ticket picker on intake screen (refactored TicketPickerModal for dual-mode)
- Ticket context card in session sidebar

Slice 2 — Auto Documentation Push:
- PSA documentation service with resolution/escalation note formatting
- Time entry creation via new ConnectWise provider method
- Automatic retry scheduler (APScheduler, 5min interval, 3 retries)
- PSA push status indicators in frontend with manual retry button
- Member mapping warning when CW member not mapped

Slice 3 — Session Pause/Resume & Escalation Handoff:
- Pause/resume endpoints for same-engineer session bookmarking
- Escalation flow: requesting_escalation status, self-escalation blocked
- Enhanced escalation package with LLM-generated hypotheses/suggestions
- Pickup endpoint with continue/fresh resume modes and briefing step
- Escalation queue (sidebar nav + dedicated page)
- SessionBriefing component with continue/fresh choice UI
- EscalateModal with PSA-aware button text

Slice 4 — Mid-Session Ticket Linking:
- Link ticket retroactively with context injection into system prompt
- Link Ticket button in session sidebar

Slice 5 — FlowPilot PSA Settings:
- Settings tab on IntegrationsPage with 7 configurable options
- Stored as flowpilot_settings JSONB on PsaConnection

Database: 2 migrations (flowpilot_settings, psa_post_log changes, status constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 01:30:05 +00:00
parent 2063a799b0
commit bbe590bfec
37 changed files with 3698 additions and 121 deletions

View File

@@ -0,0 +1,78 @@
"""phase2 psa flowpilot integration
Revision ID: bb2101378a61
Revises: f1a2b3c4d5e6
Create Date: 2026-03-18 23:05:01.099910
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bb2101378a61'
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Add flowpilot_settings JSONB to psa_connections
op.add_column('psa_connections', sa.Column(
'flowpilot_settings',
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
server_default='{}',
comment='FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.',
))
# 2. Add ai_session_id FK to psa_post_log
op.add_column('psa_post_log', sa.Column(
'ai_session_id',
sa.Uuid(),
nullable=True,
comment='FK to AI sessions (Phase 2). Original session_id FK remains for legacy sessions.',
))
op.create_index(
op.f('ix_psa_post_log_ai_session_id'),
'psa_post_log',
['ai_session_id'],
)
op.create_foreign_key(
'fk_psa_post_log_ai_session_id',
'psa_post_log',
'ai_sessions',
['ai_session_id'],
['id'],
ondelete='CASCADE',
)
# 3. Make original session_id nullable (was NOT NULL — legacy sessions only)
op.alter_column('psa_post_log', 'session_id', nullable=True)
# 4. Add retry_count and next_retry_at for automatic retries
op.add_column('psa_post_log', sa.Column(
'retry_count',
sa.Integer(),
nullable=False,
server_default='0',
comment='Number of retry attempts for failed PSA pushes',
))
op.add_column('psa_post_log', sa.Column(
'next_retry_at',
sa.DateTime(timezone=True),
nullable=True,
comment='When to attempt the next retry',
))
def downgrade() -> None:
op.drop_column('psa_post_log', 'next_retry_at')
op.drop_column('psa_post_log', 'retry_count')
op.alter_column('psa_post_log', 'session_id', nullable=False)
op.drop_constraint('fk_psa_post_log_ai_session_id', 'psa_post_log', type_='foreignkey')
op.drop_index(op.f('ix_psa_post_log_ai_session_id'), table_name='psa_post_log')
op.drop_column('psa_post_log', 'ai_session_id')
op.drop_column('psa_connections', 'flowpilot_settings')

View File

@@ -0,0 +1,35 @@
"""add requesting_escalation status
Revision ID: cc3201489b72
Revises: bb2101378a61
Create Date: 2026-03-18 23:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'cc3201489b72'
down_revision: Union[str, None] = 'bb2101378a61'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop old status constraint and recreate with new values
op.drop_constraint('ck_ai_sessions_status', 'ai_sessions', type_='check')
op.create_check_constraint(
'ck_ai_sessions_status',
'ai_sessions',
"status IN ('active', 'paused', 'resolved', 'escalated', 'requesting_escalation', 'abandoned')",
)
def downgrade() -> None:
op.drop_constraint('ck_ai_sessions_status', 'ai_sessions', type_='check')
op.create_check_constraint(
'ck_ai_sessions_status',
'ai_sessions',
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
)

View File

@@ -35,12 +35,15 @@ from app.schemas.ai_session import (
SessionCloseResponse,
SessionDocumentation,
RateSessionRequest,
PickupSessionRequest,
LinkTicketRequest,
AISessionSummary,
AISessionDetail,
AISessionStepResponse,
StepOptionSchema,
)
from app.services import flowpilot_engine
from app.services.psa_documentation_service import retry_failed_push
logger = logging.getLogger(__name__)
@@ -272,6 +275,184 @@ async def escalate_session(
return result
# ── Pause ──
@router.post("/{session_id}/pause", status_code=204)
@limiter.limit("15/minute")
async def pause_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Pause an active FlowPilot session for later resume."""
try:
await flowpilot_engine.pause_session(
session_id=session_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Resume ──
@router.post("/{session_id}/resume", status_code=204)
@limiter.limit("15/minute")
async def resume_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Resume a paused FlowPilot session."""
try:
await flowpilot_engine.resume_session(
session_id=session_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Escalation Queue ──
@router.get("/escalation-queue", response_model=list[AISessionSummary])
@limiter.limit("30/minute")
async def get_escalation_queue(
request: Request,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""List sessions requesting escalation for the current user's team."""
if not current_user.team_id:
return []
result = await db.execute(
select(AISession)
.where(
AISession.team_id == current_user.team_id,
AISession.status == "requesting_escalation",
AISession.user_id != current_user.id, # Don't show own escalated sessions
)
.order_by(AISession.created_at.desc())
)
sessions = result.scalars().all()
return [AISessionSummary.model_validate(s) for s in sessions]
# ── Pickup Escalated Session ──
@router.post("/{session_id}/pickup", response_model=StepResponseResponse)
@limiter.limit("5/minute")
async def pickup_session(
request: Request,
session_id: UUID,
data: PickupSessionRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Pick up an escalated session as a new engineer."""
_require_ai_enabled()
await _check_quota(current_user, db)
try:
result = await flowpilot_engine.pickup_session(
session_id=session_id,
resume_mode=data.resume_mode,
additional_context=data.additional_context,
user_id=current_user.id,
team_id=current_user.team_id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except Exception as e:
logger.exception("FlowPilot pickup failed: %s", e)
await _record_usage(
current_user, db,
generation_type="flowpilot_pickup",
input_tokens=0, output_tokens=0,
succeeded=False,
session_id=session_id,
error_code=type(e).__name__,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await _record_usage(
current_user, db,
generation_type="flowpilot_pickup",
input_tokens=0, output_tokens=0,
succeeded=True,
session_id=session_id,
)
await db.commit()
return result
# ── Link Ticket ──
@router.post("/{session_id}/link-ticket", response_model=AISessionDetail)
@limiter.limit("10/minute")
async def link_ticket_to_session(
request: Request,
session_id: UUID,
data: LinkTicketRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Link a PSA ticket to an in-progress session retroactively."""
try:
await flowpilot_engine.link_ticket(
session_id=session_id,
psa_ticket_id=data.psa_ticket_id,
psa_connection_id=data.psa_connection_id,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# Return updated session detail
result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps))
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
detail = AISessionDetail.model_validate(session)
return detail
# ── List sessions ──
@router.get("", response_model=list[AISessionSummary])
@@ -323,8 +504,10 @@ async def get_session(
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
# Allow access if user is owner or escalation target
if session.user_id != current_user.id and session.escalated_to_id != current_user.id:
# Allow access if user is owner, escalation target, or picked-up handler
pkg = session.escalation_package or {}
is_handler = pkg.get("picked_up_by") == str(current_user.id)
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized")
# Build step responses
@@ -409,3 +592,48 @@ async def rate_session(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Retry PSA Push ──
@router.post("/{session_id}/retry-psa-push")
@limiter.limit("5/minute")
async def retry_psa_push_endpoint(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Manually retry a failed PSA documentation push."""
from app.models.psa_post_log import PsaPostLog
# Find the latest failed push log for this session
result = await db.execute(
select(PsaPostLog)
.where(
PsaPostLog.ai_session_id == session_id,
PsaPostLog.status.in_(["failed", "pending_retry"]),
)
.order_by(PsaPostLog.posted_at.desc())
.limit(1)
)
log_entry = result.scalar_one_or_none()
if not log_entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No failed PSA push found for this session",
)
# Reset to pending_retry and attempt immediately
log_entry.status = "pending_retry"
log_entry.retry_count = max(0, log_entry.retry_count - 1) # Give one more attempt
success = await retry_failed_push(log_entry, db)
await db.commit()
return {
"psa_push_status": "sent" if success else log_entry.status,
"psa_push_error": log_entry.error_message if not success else None,
}

View File

@@ -279,6 +279,69 @@ async def test_connection(
return result
# ── FlowPilot PSA Settings ──────────────────────────────────────
@router.get("/connections/{connection_id}/flowpilot-settings")
async def get_flowpilot_settings(
connection_id: UUID,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get FlowPilot-specific settings for a PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
# Return settings with defaults filled in
defaults = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min",
"note_visibility": "internal",
"include_diagnostic_steps": True,
"prompt_status_on_resolution": False,
"prompt_status_on_escalation": False,
}
settings_data = {**defaults, **(conn.flowpilot_settings or {})}
return settings_data
@router.put("/connections/{connection_id}/flowpilot-settings")
async def update_flowpilot_settings(
connection_id: UUID,
data: dict,
current_user: Annotated[User, Depends(require_account_owner)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update FlowPilot-specific settings for a PSA connection."""
conn = await _get_connection_or_404(connection_id, current_user, db)
# Validate allowed keys
allowed_keys = {
"auto_push", "auto_time_entry", "time_rounding",
"note_visibility", "include_diagnostic_steps",
"prompt_status_on_resolution", "prompt_status_on_escalation",
}
filtered = {k: v for k, v in data.items() if k in allowed_keys}
# Merge with existing
current = conn.flowpilot_settings or {}
current.update(filtered)
conn.flowpilot_settings = current
await db.commit()
await db.refresh(conn)
defaults = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min",
"note_visibility": "internal",
"include_diagnostic_steps": True,
"prompt_status_on_resolution": False,
"prompt_status_on_escalation": False,
}
return {**defaults, **(conn.flowpilot_settings or {})}
# ── ticket / status / company endpoints ──────────────────────────

View File

@@ -180,6 +180,16 @@ async def lifespan(app: FastAPI):
replace_existing=True,
)
# PSA push retry (every 5 minutes)
from app.services.psa_retry_scheduler import process_pending_retries
scheduler.add_job(
process_pending_retries,
trigger="interval",
minutes=5,
id="psa_push_retry",
replace_existing=True,
)
# Auto-seed trees in background on PR environments
seed_task = None
if settings.SEED_ON_DEPLOY:

View File

@@ -35,7 +35,7 @@ class AISession(Base):
name="ck_ai_sessions_intake_type",
),
CheckConstraint(
"status IN ('active', 'paused', 'resolved', 'escalated', 'abandoned')",
"status IN ('active', 'paused', 'resolved', 'escalated', 'requesting_escalation', 'abandoned')",
name="ck_ai_sessions_status",
),
CheckConstraint(

View File

@@ -1,11 +1,11 @@
"""PSA connection model — one per account."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from typing import Optional, Any
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
@@ -43,6 +43,10 @@ class PsaConnection(Base):
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
flowpilot_settings: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True, server_default="{}",
comment="FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.",
)
# Relationships
account = relationship("Account", back_populates="psa_connection")

View File

@@ -3,7 +3,7 @@ import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, Text, ForeignKey
from sqlalchemy import String, DateTime, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
@@ -16,10 +16,18 @@ class PsaPostLog(Base):
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
# Legacy sessions FK (nullable for AI sessions)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sessions.id", ondelete="CASCADE"),
nullable=False,
nullable=True,
index=True,
)
# AI sessions FK (Phase 2)
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
psa_connection_id: Mapped[Optional[uuid.UUID]] = mapped_column(
@@ -35,8 +43,16 @@ class PsaPostLog(Base):
)
status: Mapped[str] = mapped_column(
String(20), nullable=False
) # 'success' or 'failed'
) # 'success', 'failed', 'pending_retry'
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Number of retry attempts for failed PSA pushes",
)
next_retry_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="When to attempt the next retry",
)
status_changed_from: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True
)
@@ -54,5 +70,6 @@ class PsaPostLog(Base):
# Relationships
session = relationship("Session", foreign_keys=[session_id])
ai_session = relationship("AISession", foreign_keys=[ai_session_id])
psa_connection = relationship("PsaConnection", foreign_keys=[psa_connection_id])
user = relationship("User", foreign_keys=[posted_by])

View File

@@ -42,6 +42,7 @@ class AISessionCreateResponse(BaseModel):
matched_flow_name: str | None = None
match_score: float | None = None
first_step: AISessionStepResponse
psa_context_status: str | None = None # loaded | unavailable | None (no PSA)
# ── Step interaction ──
@@ -131,6 +132,9 @@ class SessionCloseResponse(BaseModel):
session_id: UUID
status: str
documentation: SessionDocumentation
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None
class RateSessionRequest(BaseModel):
@@ -139,6 +143,18 @@ class RateSessionRequest(BaseModel):
feedback: str | None = None
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = Field("continue", pattern="^(continue|fresh)$")
additional_context: str | None = None
class LinkTicketRequest(BaseModel):
"""Link a PSA ticket to an in-progress session."""
psa_ticket_id: str
psa_connection_id: UUID
# ── List / Detail ──
class AISessionSummary(BaseModel):
@@ -151,6 +167,8 @@ class AISessionSummary(BaseModel):
confidence_tier: str
step_count: int
session_rating: int | None = None
psa_ticket_id: str | None = None
escalation_reason: str | None = None
created_at: datetime
resolved_at: datetime | None = None
@@ -166,6 +184,9 @@ class AISessionDetail(AISessionSummary):
resolution_action: str | None = None
escalation_reason: str | None = None
session_feedback: str | None = None
psa_ticket_id: str | None = None
psa_connection_id: UUID | None = None
ticket_data: dict[str, Any] | None = None
steps: list[AISessionStepResponse] = []
model_config = {"from_attributes": True}

View File

@@ -170,8 +170,32 @@ async def start_session(
) -> AISessionCreateResponse:
"""Start a new FlowPilot session: classify intake, match flows, get first step."""
# 0. Process PSA ticket intake if applicable
ticket_context_block = None
ticket_data = None
psa_context_status = None
if request.intake_type == "psa_ticket" and request.psa_connection_id and request.psa_ticket_id:
ticket_context_block, ticket_data, psa_context_status = await _process_ticket_intake(
psa_connection_id=request.psa_connection_id,
psa_ticket_id=request.psa_ticket_id,
db=db,
)
# Enrich intake content with ticket context for classification
if ticket_data:
enriched_content = dict(request.intake_content)
enriched_content["ticket_data"] = {
"summary": ticket_data.get("ticket", {}).get("summary", ""),
"company": ticket_data.get("company", {}).get("name", ""),
"priority": ticket_data.get("ticket", {}).get("priority", ""),
}
request = request.model_copy(update={"intake_content": enriched_content})
# 1. Classify intake via fast LLM call
intake_text = _extract_intake_text(request.intake_content)
# Include ticket context in classification text if available
if ticket_context_block:
intake_text = f"{ticket_context_block}\n\n{intake_text}"
classification = await _classify_intake(intake_text)
# 2. Try to match existing flows
@@ -199,9 +223,14 @@ async def start_session(
f"Use it as a guide but adapt to the specific situation."
)
# Include ticket context in system prompt if available
ticket_prompt_section = ""
if ticket_context_block:
ticket_prompt_section = f"\n## PSA TICKET CONTEXT\n{ticket_context_block}\n"
system_prompt = FLOWPILOT_SYSTEM_PROMPT.format(
structured_output_schema=STRUCTURED_OUTPUT_SCHEMA,
team_context="", # Phase 2: team-specific context
team_context=ticket_prompt_section,
matched_flow_context=matched_flow_context,
)
@@ -261,6 +290,7 @@ async def start_session(
match_score=match_score,
psa_ticket_id=request.psa_ticket_id,
psa_connection_id=request.psa_connection_id,
ticket_data=ticket_data,
total_input_tokens=input_tokens,
total_output_tokens=output_tokens,
step_count=1,
@@ -294,6 +324,7 @@ async def start_session(
matched_flow_name=matched_flow_name,
match_score=match_score,
first_step=_build_step_response(step, session),
psa_context_status=psa_context_status,
)
@@ -419,10 +450,14 @@ async def resolve_session(
await db.flush()
# Push documentation to PSA if ticket is linked
psa_result = await _push_to_psa(session, user_id, db)
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
**psa_result,
)
@@ -432,31 +467,276 @@ async def escalate_session(
user_id: UUID,
db: AsyncSession,
) -> SessionCloseResponse:
"""Escalate a session to another engineer."""
"""Escalate a session — sets status to requesting_escalation for pickup."""
session = await _load_session(session_id, user_id, db)
if session.status not in ("active", "paused"):
raise ValueError(f"Cannot escalate session in status: {session.status}")
session.status = "escalated"
session.resolved_at = datetime.now(timezone.utc)
# Block self-escalation
if request.escalated_to_id and request.escalated_to_id == user_id:
raise ValueError("Cannot escalate a session to yourself. Use pause instead.")
session.status = "requesting_escalation"
# Don't set resolved_at — session isn't done yet
session.escalation_reason = request.escalation_reason
session.escalated_to_id = request.escalated_to_id
# Build escalation package
session.escalation_package = _build_escalation_package(session)
# Build enhanced escalation package
session.escalation_package = await _build_escalation_package_enhanced(session, user_id)
documentation = _generate_documentation(session)
await db.flush()
# Push documentation to PSA if ticket is linked
psa_result = await _push_to_psa(session, user_id, db)
return SessionCloseResponse(
session_id=session.id,
status=session.status,
documentation=documentation,
**psa_result,
)
async def pickup_session(
session_id: UUID,
resume_mode: str,
additional_context: Optional[str],
user_id: UUID,
team_id: Optional[UUID],
db: AsyncSession,
) -> StepResponseResponse:
"""Pick up an escalated session as a new engineer.
Generates a briefing step summarizing prior work, then either continues
the conversation or starts fresh with the new engineer's context.
"""
session = await _load_session(
session_id, user_id, db,
allow_team_access=True, team_id=team_id,
)
if session.status != "requesting_escalation":
raise ValueError(f"Session is {session.status}, not requesting_escalation")
# Can't pick up your own session
if session.user_id == user_id:
raise ValueError("Cannot pick up your own escalated session")
# Record the pickup in the escalation package
pkg = session.escalation_package or {}
pkg["picked_up_by"] = str(user_id)
pkg["picked_up_at"] = datetime.now(timezone.utc).isoformat()
session.escalation_package = pkg
# Reactivate the session
session.status = "active"
# Build a briefing message for the new engineer
original_user_name = "the previous engineer"
if session.user and hasattr(session.user, 'display_name') and session.user.display_name:
original_user_name = session.user.display_name
briefing_parts = [
f"## Escalation Briefing",
f"**Escalated by:** {original_user_name}",
f"**Reason:** {session.escalation_reason or 'Not specified'}",
"",
f"**Problem:** {session.problem_summary or 'Unknown'}",
]
steps_tried = pkg.get("steps_tried", [])
if steps_tried:
briefing_parts.append("")
briefing_parts.append("**Steps already taken:**")
for i, step in enumerate(steps_tried, 1):
desc = step.get("description", "")
resp = step.get("response", "")
briefing_parts.append(f"{i}. {desc}")
if resp:
briefing_parts.append(f"{resp}")
if hypotheses := pkg.get("remaining_hypotheses"):
briefing_parts.append("")
briefing_parts.append("**Remaining hypotheses:**")
if isinstance(hypotheses, list):
for h in hypotheses:
briefing_parts.append(f"- {h}")
else:
briefing_parts.append(str(hypotheses))
if suggestions := pkg.get("suggested_next_steps"):
briefing_parts.append("")
briefing_parts.append("**Suggested next steps:**")
if isinstance(suggestions, list):
for s in suggestions:
briefing_parts.append(f"- {s}")
else:
briefing_parts.append(str(suggestions))
briefing_text = "\n".join(briefing_parts)
# Create a briefing step (special intake_analysis type)
briefing_step = AISessionStep(
id=uuid.uuid4(),
session_id=session.id,
step_order=session.step_count,
step_type="action",
content={
"text": briefing_text,
"type": "briefing",
"allow_free_text": False,
"allow_skip": False,
},
context_message="Escalation briefing — here's what was tried before you.",
confidence_at_step=session.confidence_score,
ai_reasoning="Escalation handoff briefing for receiving engineer",
input_tokens=0,
output_tokens=0,
)
db.add(briefing_step)
session.step_count += 1
# Now generate the next step based on resume_mode
if resume_mode == "fresh" and additional_context:
# Engineer B provides their own input
user_message = f"[Picking up escalated session] {additional_context}"
else:
# Continue where A left off
user_message = (
"[Picking up escalated session] I've reviewed the briefing above. "
"Please continue the diagnosis based on everything tried so far."
)
# Append to conversation
session.conversation_messages = session.conversation_messages + [
{"role": "user", "content": user_message}
]
# Call LLM for next step
provider = get_ai_provider(settings.get_model_for_action("open_chat"))
raw_response, input_tokens, output_tokens = await provider.generate_json(
system_prompt=session.system_prompt_snapshot or "",
messages=session.conversation_messages,
max_tokens=2048,
)
try:
parsed = _parse_structured_output(raw_response)
except ValueError:
retry_messages = session.conversation_messages + [
{"role": "assistant", "content": raw_response},
{"role": "user", "content": "Please respond with ONLY valid JSON matching the required schema."},
]
raw_response, retry_in, retry_out = await provider.generate_json(
system_prompt=session.system_prompt_snapshot or "",
messages=retry_messages,
max_tokens=2048,
)
input_tokens += retry_in
output_tokens += retry_out
parsed = _parse_structured_output(raw_response)
session.conversation_messages = session.conversation_messages + [
{"role": "assistant", "content": raw_response}
]
confidence = parsed.get("confidence", session.confidence_score)
session.confidence_score = confidence
session.confidence_tier = _confidence_to_tier(confidence)
session.total_input_tokens += input_tokens
session.total_output_tokens += output_tokens
session.step_count += 1
next_step = _create_step_from_parsed(
session_id=session.id,
step_order=session.step_count - 1,
parsed=parsed,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
db.add(next_step)
await db.flush()
return StepResponseResponse(
session_id=session.id,
status=session.status,
confidence_tier=session.confidence_tier,
confidence_score=session.confidence_score,
next_step=_build_step_response(next_step, session),
resolution_suggested=parsed["type"] == "resolution_suggestion",
resolution_summary=parsed.get("resolution_summary") if parsed["type"] == "resolution_suggestion" else None,
)
async def link_ticket(
session_id: UUID,
psa_ticket_id: str,
psa_connection_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> None:
"""Link a PSA ticket to an in-progress session and inject context."""
session = await _load_session(session_id, user_id, db)
if session.status not in ("active", "paused"):
raise ValueError(f"Cannot link ticket to session in status: {session.status}")
# Store the ticket link
session.psa_ticket_id = psa_ticket_id
session.psa_connection_id = psa_connection_id
# Try to fetch ticket context
ticket_context_block, ticket_data, _ = await _process_ticket_intake(
psa_connection_id=psa_connection_id,
psa_ticket_id=psa_ticket_id,
db=db,
)
if ticket_data:
session.ticket_data = ticket_data
# Inject ticket context into the system prompt for subsequent steps
if ticket_context_block and session.system_prompt_snapshot:
ticket_section = f"\n\n## PSA TICKET CONTEXT (linked mid-session)\n{ticket_context_block}\n"
session.system_prompt_snapshot = session.system_prompt_snapshot + ticket_section
await db.flush()
async def pause_session(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> None:
"""Pause an active session for the same engineer to resume later."""
session = await _load_session(session_id, user_id, db)
if session.status != "active":
raise ValueError(f"Cannot pause session in status: {session.status}")
session.status = "paused"
await db.flush()
async def resume_session(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
) -> None:
"""Resume a paused session for the same engineer."""
session = await _load_session(session_id, user_id, db)
if session.status != "paused":
raise ValueError(f"Cannot resume session in status: {session.status}")
session.status = "active"
await db.flush()
async def rate_session(
session_id: UUID,
rating: int,
@@ -487,11 +767,23 @@ async def _load_session(
session_id: UUID,
user_id: UUID,
db: AsyncSession,
allow_team_access: bool = False,
team_id: Optional[UUID] = None,
) -> AISession:
"""Load session with steps, verifying ownership."""
"""Load session with steps and user relationships, verifying ownership.
Args:
allow_team_access: If True, same-team users can access sessions in
'requesting_escalation' status (for escalation pickup).
team_id: Required when allow_team_access is True.
"""
result = await db.execute(
select(AISession)
.options(selectinload(AISession.steps))
.options(
selectinload(AISession.steps),
selectinload(AISession.user),
selectinload(AISession.escalated_to),
)
.where(AISession.id == session_id)
)
session = result.scalar_one_or_none()
@@ -499,11 +791,21 @@ async def _load_session(
if not session:
raise ValueError("Session not found")
# Allow access if user is the session owner or the escalation target
if session.user_id != user_id and session.escalated_to_id != user_id:
raise PermissionError("Not authorized to access this session")
# Owner or escalation target always has access
if session.user_id == user_id or session.escalated_to_id == user_id:
return session
return session
# Engineer who picked up an escalated session has access
pkg = session.escalation_package or {}
if pkg.get("picked_up_by") == str(user_id):
return session
# Team-based access for escalation pickup
if allow_team_access and team_id and session.team_id == team_id:
if session.status == "requesting_escalation":
return session
raise PermissionError("Not authorized to access this session")
async def _classify_intake(intake_text: str) -> dict[str, Any]:
@@ -708,8 +1010,67 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
)
def _build_escalation_package(session: AISession) -> dict[str, Any]:
"""Build context package for the receiving engineer."""
async def _push_to_psa(
session: AISession,
user_id: UUID,
db: AsyncSession,
) -> dict[str, Any]:
"""Push documentation to PSA if session has a linked ticket.
Returns dict with psa_push_status, psa_push_error, member_mapping_warning.
"""
if not session.psa_ticket_id or not session.psa_connection_id:
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
try:
from app.services.psa_documentation_service import push_documentation
return await push_documentation(session, user_id, db)
except Exception as e:
logger.warning("PSA documentation push failed for session %s: %s", session.id, e)
return {
"psa_push_status": "failed",
"psa_push_error": str(e)[:200],
"member_mapping_warning": None,
}
async def _process_ticket_intake(
psa_connection_id: UUID,
psa_ticket_id: str,
db: AsyncSession,
) -> tuple[Optional[str], Optional[dict[str, Any]], str]:
"""Fetch ticket context from PSA and format for AI prompt.
Returns:
(ticket_context_block, ticket_data_dict, psa_context_status)
- ticket_context_block: formatted text for system prompt, or None on failure
- ticket_data_dict: serialized TicketContext for storage, or None on failure
- psa_context_status: "loaded" or "unavailable"
"""
try:
from app.services.psa.registry import get_provider_for_connection
from app.services.psa.ticket_context import format_ticket_context_for_prompt
provider = await get_provider_for_connection(psa_connection_id, db)
ticket_context = await provider.get_ticket_context(
int(psa_ticket_id), str(psa_connection_id)
)
ticket_prompt_block = format_ticket_context_for_prompt(ticket_context)
ticket_data = ticket_context.model_dump(mode="json")
return ticket_prompt_block, ticket_data, "loaded"
except Exception as e:
logger.warning(
"Failed to fetch ticket context for ticket %s (connection %s): %s",
psa_ticket_id, psa_connection_id, e,
)
return None, None, "unavailable"
async def _build_escalation_package_enhanced(
session: AISession,
user_id: UUID,
) -> dict[str, Any]:
"""Build enhanced context package with LLM-generated hypotheses."""
steps_tried = []
for step in session.steps:
content = step.content or {}
@@ -727,7 +1088,8 @@ def _build_escalation_package(session: AISession) -> dict[str, Any]:
entry["action_result"] = step.action_result
steps_tried.append(entry)
return {
package = {
"original_user_id": str(user_id),
"problem_summary": session.problem_summary,
"problem_domain": session.problem_domain,
"intake_content": session.intake_content,
@@ -735,3 +1097,36 @@ def _build_escalation_package(session: AISession) -> dict[str, Any]:
"steps_tried": steps_tried,
"escalation_reason": session.escalation_reason,
}
# LLM call for remaining hypotheses and suggested next steps (fast model)
try:
conversation_summary = "\n".join(
f"- {s.get('description', '')}{s.get('response', 'no response')}"
for s in steps_tried
)
prompt = (
"Based on this diagnostic conversation for an IT troubleshooting session:\n\n"
f"Problem: {session.problem_summary}\n"
f"Domain: {session.problem_domain}\n\n"
f"Steps taken:\n{conversation_summary}\n\n"
f"Escalation reason: {session.escalation_reason}\n\n"
"Respond with ONLY a JSON object:\n"
'{"remaining_hypotheses": ["hypothesis1", "hypothesis2"], '
'"suggested_next_steps": ["step1", "step2"], '
'"steps_ruled_out": ["ruled_out1"]}'
)
provider = get_ai_provider(settings.get_model_for_action("quick_action"))
raw, _, _ = await provider.generate_json(
system_prompt="You are an expert IT diagnostic assistant. Analyze the escalation context and provide concise insights.",
messages=[{"role": "user", "content": prompt}],
max_tokens=1024,
)
insights = json.loads(raw.strip().strip("`").lstrip("json\n"))
package["remaining_hypotheses"] = insights.get("remaining_hypotheses", [])
package["suggested_next_steps"] = insights.get("suggested_next_steps", [])
package["steps_ruled_out"] = insights.get("steps_ruled_out", [])
except Exception as e:
logger.warning("Failed to generate escalation insights: %s", e)
# Fall back gracefully — don't block the escalation
return package

View File

@@ -11,6 +11,7 @@ from .types import (
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
)
@@ -66,3 +67,14 @@ class PSAProvider(ABC):
@abstractmethod
async def get_ticket_configurations(self, ticket_id: str) -> list[PSAConfiguration]:
...
@abstractmethod
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
...

View File

@@ -15,6 +15,7 @@ from app.services.psa.types import (
PSACompany,
PSAMember,
PSAConfiguration,
PSATimeEntry,
)
from .client import ConnectWiseClient
@@ -514,6 +515,37 @@ class ConnectWiseProvider(PSAProvider):
psa_cache.set(cache_key, ctx, ttl_seconds=300)
return ctx
async def create_time_entry(
self,
ticket_id: str,
member_id: str,
hours: float,
notes: str | None = None,
work_type: str | None = None,
) -> PSATimeEntry:
"""Create a time entry on a CW ticket via POST /time/entries."""
payload: dict = {
"chargeToId": int(ticket_id),
"chargeToType": "ServiceTicket",
"member": {"id": int(member_id)},
"timeStart": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"actualHours": hours,
}
if notes:
payload["notes"] = notes[:2000] # CW limit
if work_type:
payload["workType"] = {"name": work_type}
data = await self._client.post("/time/entries", payload)
return PSATimeEntry(
id=str(data["id"]),
ticket_id=ticket_id,
member_id=member_id,
hours=data.get("actualHours", hours),
notes=data.get("notes"),
created_at=data.get("timeStart"),
)
# ── Private helpers ───────────────────────────────────────────────
@staticmethod

View File

@@ -31,6 +31,32 @@ async def get_provider_for_account(
provider="unknown",
)
return _instantiate_provider(connection)
async def get_provider_for_connection(
connection_id: UUID, db: AsyncSession
) -> PSAProvider:
"""Look up a specific PSA connection by ID, decrypt credentials, instantiate provider."""
result = await db.execute(
select(PsaConnection).where(
PsaConnection.id == connection_id,
PsaConnection.is_active.is_(True),
)
)
connection = result.scalar_one_or_none()
if not connection:
raise PSAConnectionError(
"PSA connection not found or inactive.",
provider="unknown",
)
return _instantiate_provider(connection)
def _instantiate_provider(connection: PsaConnection) -> PSAProvider:
"""Create the appropriate provider instance from a connection record."""
if connection.provider == "connectwise":
from app.services.psa.connectwise.client import ConnectWiseClient
from app.services.psa.connectwise.provider import ConnectWiseProvider

View File

@@ -57,6 +57,16 @@ class PSAConfiguration(BaseModel):
company_name: str | None = None
class PSATimeEntry(BaseModel):
id: str
ticket_id: str
member_id: str | None = None
hours: float
notes: str | None = None
work_type: str | None = None
created_at: str | None = None
class NoteType:
INTERNAL_ANALYSIS = "internal_analysis"
RESOLUTION = "resolution"

View File

@@ -0,0 +1,402 @@
"""PSA Documentation Push Service.
Generates structured documentation from FlowPilot AI sessions and pushes
it back to ConnectWise as internal notes + optional time entries.
"""
import logging
import math
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_session import AISession
from app.models.psa_connection import PsaConnection
from app.models.psa_member_mapping import PsaMemberMapping
from app.models.psa_post_log import PsaPostLog
from app.services.psa.registry import get_provider_for_connection
from app.services.psa.types import NoteType
from app.services.redaction_service import apply_redaction_to_text
logger = logging.getLogger(__name__)
# Default flowpilot_settings values
DEFAULT_SETTINGS = {
"auto_push": True,
"auto_time_entry": True,
"time_rounding": "15min", # "15min", "30min", "exact", "none"
"note_visibility": "internal", # "internal", "both"
"include_diagnostic_steps": True,
}
def _get_setting(connection: PsaConnection, key: str) -> Any:
"""Get a flowpilot setting with default fallback."""
settings = connection.flowpilot_settings or {}
return settings.get(key, DEFAULT_SETTINGS.get(key))
def _round_hours(hours: float, rounding: str) -> float:
"""Round hours according to the rounding setting."""
if rounding == "exact":
return round(hours, 2)
elif rounding == "30min":
return math.ceil(hours * 2) / 2
else: # default 15min
return math.ceil(hours * 4) / 4
def _format_datetime(dt: datetime | None) -> str:
"""Format a datetime for display in notes."""
if not dt:
return "N/A"
return dt.strftime("%Y-%m-%d %I:%M %p UTC")
def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
"""Format a resolved session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Session Documentation ═══",
f"Session: {session.id}",
]
# Engineer name from relationship if loaded, otherwise user_id
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'display_name'):
lines.append(f"Engineer: {engineer_name.display_name}")
lines.extend([
f"Date: {_format_datetime(session.resolved_at)}",
f"Started: {_format_datetime(session.created_at)}",
f"Ended: {_format_datetime(session.resolved_at)}",
])
# Duration
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
lines.append(f"Duration: {minutes}m")
else:
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
if session.problem_domain:
lines.append(f"Domain: {session.problem_domain}")
# Diagnostic steps
if include_steps and session.steps:
lines.append("")
lines.append("── Diagnosis Path ──")
for step in session.steps:
content = step.content or {}
step_type = content.get("type", step.step_type).capitalize()
description = content.get("text", "")
response_text = ""
if step.was_skipped:
response_text = "Skipped"
elif step.selected_option:
# Try to find the label
if step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response_text = opt.get("label", step.selected_option)
break
else:
response_text = step.selected_option
else:
response_text = step.selected_option
elif step.free_text_input:
response_text = step.free_text_input
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
if response_text:
lines.append(f" → Response: {response_text}")
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
lines.append(f" → Result: {outcome}")
# Resolution
lines.append("")
lines.append("── Resolution ──")
lines.append(session.resolution_summary or "No resolution summary")
if session.resolution_action:
lines.append(session.resolution_action)
# Confidence
lines.append("")
lines.append("── AI Confidence ──")
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})")
# Timing section (always present)
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
lines.append(f"End: {_format_datetime(session.resolved_at)}")
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
return "\n".join(lines)
def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
"""Format an escalated session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Escalation Documentation ═══",
f"Session: {session.id}",
]
engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'display_name'):
lines.append(f"Escalated by: {engineer_name.display_name}")
escalated_to = getattr(session, 'escalated_to', None)
if escalated_to and hasattr(escalated_to, 'display_name'):
lines.append(f"Escalated to: {escalated_to.display_name}")
else:
lines.append("Escalated to: Unassigned")
lines.extend([
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
f"Started: {_format_datetime(session.created_at)}",
])
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
# Work completed
if include_steps and session.steps:
lines.append("")
lines.append("── Work Completed ──")
for step in session.steps:
content = step.content or {}
description = content.get("text", "")
lines.append(f"{step.step_order + 1}. {description}")
# Escalation reason
lines.append("")
lines.append("── Escalation Reason ──")
lines.append(session.escalation_reason or "No reason provided")
# Escalation package details
pkg = session.escalation_package or {}
if hypotheses := pkg.get("remaining_hypotheses"):
lines.append("")
lines.append("── Remaining Hypotheses ──")
if isinstance(hypotheses, list):
for h in hypotheses:
lines.append(f"- {h}")
else:
lines.append(str(hypotheses))
if suggestions := pkg.get("suggested_next_steps"):
lines.append("")
lines.append("── Suggested Next Steps ──")
if isinstance(suggestions, list):
for s in suggestions:
lines.append(f"- {s}")
else:
lines.append(str(suggestions))
# Timing
lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}")
escalated_at = session.resolved_at or datetime.now(timezone.utc)
lines.append(f"Escalated: {_format_datetime(escalated_at)}")
if session.created_at:
delta = escalated_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m")
lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot")
return "\n".join(lines)
async def push_documentation(
session: AISession,
user_id: UUID,
db: AsyncSession,
) -> dict[str, Any]:
"""Push session documentation to PSA ticket.
Returns:
{
"psa_push_status": "sent" | "pending_retry" | "failed" | "no_psa",
"psa_push_error": str | None,
"member_mapping_warning": str | None,
}
"""
if not session.psa_ticket_id or not session.psa_connection_id:
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
# Load connection and check settings
result = await db.execute(
select(PsaConnection).where(PsaConnection.id == session.psa_connection_id)
)
connection = result.scalar_one_or_none()
if not connection:
return {"psa_push_status": "failed", "psa_push_error": "PSA connection not found", "member_mapping_warning": None}
if not _get_setting(connection, "auto_push"):
return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None}
# Format the note
include_steps = _get_setting(connection, "include_diagnostic_steps")
if session.status == "resolved":
note_text = format_resolution_note(session, include_steps=include_steps)
else:
note_text = format_escalation_note(session, include_steps=include_steps)
# Redact sensitive data
note_text, _ = apply_redaction_to_text(note_text)
# Determine note type
visibility = _get_setting(connection, "note_visibility")
note_type = NoteType.INTERNAL_ANALYSIS if visibility == "internal" else NoteType.DESCRIPTION
# Check member mapping for time entry
member_mapping_warning = None
member_mapping = None
if _get_setting(connection, "auto_time_entry") and _get_setting(connection, "time_rounding") != "none":
mapping_result = await db.execute(
select(PsaMemberMapping).where(
PsaMemberMapping.psa_connection_id == session.psa_connection_id,
PsaMemberMapping.user_id == user_id,
)
)
member_mapping = mapping_result.scalar_one_or_none()
if not member_mapping:
member_mapping_warning = "Map your CW account in Settings → Integrations to enable auto-logged time entries."
# Push to PSA
try:
provider = await get_provider_for_connection(session.psa_connection_id, db)
# Post the note
posted_note = await provider.post_note(
ticket_id=session.psa_ticket_id,
text=note_text,
note_type=note_type,
)
# Create time entry if member mapping exists
if member_mapping and session.resolved_at and session.created_at:
try:
delta = session.resolved_at - session.created_at
hours = delta.total_seconds() / 3600
rounding = _get_setting(connection, "time_rounding")
rounded_hours = _round_hours(hours, rounding)
if rounded_hours > 0:
await provider.create_time_entry(
ticket_id=session.psa_ticket_id,
member_id=member_mapping.external_member_id,
hours=rounded_hours,
notes=f"FlowPilot session: {session.problem_summary or 'Troubleshooting'}",
)
except Exception as e:
logger.warning("Failed to create time entry for session %s: %s", session.id, e)
# Don't fail the note push just because time entry failed
# Log success
log_entry = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,
note_type=note_type,
content_posted=note_text[:10000], # Truncate for storage
external_note_id=posted_note.id,
status="success",
posted_by=user_id,
)
db.add(log_entry)
return {
"psa_push_status": "sent",
"psa_push_error": None,
"member_mapping_warning": member_mapping_warning,
}
except Exception as e:
logger.warning("PSA push failed for session %s: %s", session.id, e)
# Log failure with retry scheduling
log_entry = PsaPostLog(
id=uuid.uuid4(),
ai_session_id=session.id,
psa_connection_id=session.psa_connection_id,
ticket_id=session.psa_ticket_id,
note_type=note_type,
content_posted=note_text[:10000],
status="pending_retry",
error_message=str(e)[:500],
retry_count=0,
next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5),
posted_by=user_id,
)
db.add(log_entry)
return {
"psa_push_status": "pending_retry",
"psa_push_error": str(e)[:200],
"member_mapping_warning": member_mapping_warning,
}
async def retry_failed_push(
log_entry: PsaPostLog,
db: AsyncSession,
) -> bool:
"""Retry a failed PSA push. Returns True on success."""
try:
provider = await get_provider_for_connection(log_entry.psa_connection_id, db)
posted_note = await provider.post_note(
ticket_id=log_entry.ticket_id,
text=log_entry.content_posted,
note_type=log_entry.note_type,
)
log_entry.status = "success"
log_entry.external_note_id = posted_note.id
log_entry.error_message = None
log_entry.next_retry_at = None
return True
except Exception as e:
log_entry.retry_count += 1
log_entry.error_message = str(e)[:500]
if log_entry.retry_count >= 3:
log_entry.status = "failed"
log_entry.next_retry_at = None
else:
# Exponential backoff: 5min, 15min, 45min
backoff_minutes = 5 * (3 ** log_entry.retry_count)
log_entry.next_retry_at = datetime.now(timezone.utc) + timedelta(minutes=backoff_minutes)
logger.warning(
"PSA retry %d failed for log %s: %s",
log_entry.retry_count, log_entry.id, e,
)
return False

View File

@@ -0,0 +1,52 @@
"""Background scheduler for retrying failed PSA documentation pushes.
Runs every 5 minutes via APScheduler, picks up PsaPostLog entries
with status='pending_retry' and next_retry_at <= now.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import async_session_maker
from app.models.psa_post_log import PsaPostLog
from app.services.psa_documentation_service import retry_failed_push
logger = logging.getLogger(__name__)
async def process_pending_retries() -> None:
"""Process all pending PSA push retries that are due."""
async with async_session_maker() as db:
try:
result = await db.execute(
select(PsaPostLog)
.where(
PsaPostLog.status == "pending_retry",
PsaPostLog.next_retry_at <= datetime.now(timezone.utc),
PsaPostLog.retry_count < 3,
)
.limit(20) # Process in batches
)
entries = result.scalars().all()
if not entries:
return
logger.info("Processing %d pending PSA push retries", len(entries))
for entry in entries:
success = await retry_failed_push(entry, db)
if success:
logger.info("PSA retry succeeded for log %s", entry.id)
else:
logger.warning(
"PSA retry %d/%d failed for log %s",
entry.retry_count, 3, entry.id,
)
await db.commit()
except Exception as e:
logger.error("PSA retry scheduler error: %s", e)
await db.rollback()

View File

@@ -0,0 +1,885 @@
# FlowPilot-First Pivot — Phase 2: PSA Integration & Escalation Handoff
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Connect FlowPilot to ConnectWise PSA so engineers can start sessions from tickets, and documentation flows back automatically on resolution or escalation. Also implement escalation handoffs with full context briefing, session pause/resume for individual engineers, and in-app escalation notifications.
**Architecture:** Builds on existing PSA infrastructure (`services/psa/`, `PsaConnection` model, ConnectWise client) and Phase 1 AI session models (`AISession`, `AISessionStep`, `FlowPilotEngine`). Adds PSA ticket intake to sessions, auto-documentation push on close, session pause/resume, and escalation handoff mechanics.
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), httpx (ConnectWise API), React, TypeScript, Tailwind CSS v4, shadcn/ui
**Prerequisites:**
- Phase 1 complete (AI session core — models, engine, API, frontend)
- Existing PSA integration (`docs/plans/2026-03-14-connectwise-psa-integration-plan.md`)
- Existing models: `PsaConnection`, `PsaMemberMapping`, `PsaPostLog`
- Existing services: `services/psa/base.py`, `services/psa/connectwise/client.py`, `services/psa/connectwise/provider.py`
- Existing service: `services/psa/ticket_context.py` — has `format_ticket_context_for_prompt()` already
- Existing schemas: `schemas/psa_context.py` — has `TicketContext`, `TicketDetails`, `CompanyInfo`, `ConfigItem`, `TicketNote`, etc.
- Existing frontend: `TicketPickerModal.tsx`, `TicketContextPanel.tsx`, `IntegrationsPage.tsx`
- Existing service: `services/redaction_service.py` — has `apply_redaction_to_text()` for password redaction
**Existing patterns to follow:**
- PSA: `app/services/psa/` — abstract `PSAProvider` interface + ConnectWise implementation
- PSA context: `app/services/psa/connectwise/provider.py``get_ticket_context()` already fetches ticket + company + contact + configs + notes + related tickets in parallel with caching
- PSA prompt formatting: `app/services/psa/ticket_context.py``format_ticket_context_for_prompt()` already formats `TicketContext` into structured text for AI prompts
- Sessions: `app/api/endpoints/sessions.py` — existing ticket linking patterns
- Phase 1: `app/services/flowpilot_engine.py`, `app/api/endpoints/ai_sessions.py`
- Frontend API pattern: `src/api/aiSessions.ts` uses `aiSessionsApi` object pattern (not standalone exports)
- Frontend ticket UI: `src/components/session/TicketPickerModal.tsx` (note: currently takes `sessionId` prop for old sessions — needs adapter)
---
## Key Design Decisions (from product review)
These decisions were confirmed during product review before implementation:
1. **PSA connection scope:** Per-account (one CW connection per MSP). Individual engineers mapped to CW members via `PsaMemberMapping`.
2. **CW API failure at intake:** Graceful degradation — engineer can manually type ticket number and paste ticket notes. Session starts without rich context. Ticket can be linked later to pull in contact/company details.
3. **Missing CW member mapping for time entries:** Show warning "Map your CW account in Settings to enable auto-logged time entries." Always include start time, end time, and total duration in the note text regardless.
4. **PSA push failure retry:** Automatic background retries via APScheduler (up to 3 attempts, exponential backoff). Plus a manual "Retry" button that only appears when auto-retries are exhausted or push is in failed state.
5. **Session ownership on escalation:** Engineer A **keeps ownership** (`session.user_id` unchanged). Session goes to `requesting_escalation` status. Engineer B works within the same session but A remains the originator. Both see it in their history.
6. **Escalation vs Pause:** Two separate features:
- **Pause/Resume** — same engineer, bookmark for later or recover from browser crash. Status: `paused`.
- **Escalation** — handoff to another engineer with context briefing. Status: `requesting_escalation``escalated` (when picked up). Self-escalation blocked.
7. **Escalation queue location:** Both — sidebar nav item "Escalations" with badge count AND a tab in session history page.
8. **Escalation pickup UX:** Engineer B sees a briefing card summarizing A's work, then chooses: (a) "Continue where they left off" (picks up same conversation), or (b) "Start fresh with context" (types their own input, but FlowPilot knows everything A tried so it won't repeat steps).
9. **Mid-session ticket linking:** Inject ticket context into system prompt immediately. FlowPilot naturally acknowledges the new context in its next response ("Thanks for linking that ticket. I can see this is for [client]...").
10. **Ticket status on resolve:** Contextual dropdown pulled dynamically from the linked ticket's board statuses (not a global setting). Admin setting just controls whether engineers are prompted to pick a status.
11. **Tests:** Mocked CW responses based on OpenAPI spec. No sandbox available yet.
---
## Context: What Phase 2 Adds
Phase 1 delivered FlowPilot with free-text intake only. Phase 2 makes it a ticket workflow tool:
**PSA Ticket Intake:** Engineer selects a ConnectWise ticket → FlowPilot pulls ticket data (summary, client, priority, history, configuration items) and uses it as rich context for diagnosis. If CW is unavailable, engineer can manually enter ticket number and paste notes — graceful degradation, not a hard block.
**Auto Documentation Push:** On resolution or escalation, FlowPilot auto-generates documentation and pushes it back to the ConnectWise ticket as internal notes + time entry. Automatic background retries on failure, with manual retry fallback. Notes always include session timing (start, end, duration) even if time entry creation fails due to missing member mapping.
**Session Pause/Resume:** Engineer can pause a session and come back later, or recover seamlessly from browser crashes and page reloads. Same engineer, same session.
**Escalation Handoff:** Engineer A hits a wall → clicks Escalate → FlowPilot packages everything tried so far → session goes to "requesting escalation" status → Engineer B sees it in the escalation queue (sidebar + session history tab) → picks it up with a briefing card → chooses to continue where A left off or start fresh with full context. Engineer A retains session ownership throughout. Self-escalation is blocked.
---
## Slice 1: PSA Ticket Intake for AI Sessions
### Task 1: Extend FlowPilotEngine to accept PSA ticket intake
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
**What to add:**
Add a new method `_process_ticket_intake()` that:
1. Receives `psa_connection_id` and `psa_ticket_id` from the intake request
2. Loads the `PsaConnection` from the database
3. **Attempts** to use `ConnectWiseProvider.get_ticket_context()` — if this fails (API down, bad credentials), catch the error and fall back gracefully (session starts with just the ticket ID stored, no rich context)
4. On success: stores the full ticket context (serialized `TicketContext`) in `session.ticket_data` JSONB
5. Uses the existing `format_ticket_context_for_prompt()` from `services/psa/ticket_context.py` to build the system prompt context block — do NOT rewrite this formatting, it already handles all fields correctly
6. Builds enriched intake content that includes both the formatted ticket context and any additional free-text the engineer provided
7. Passes the enriched context to `_classify_intake()` and the system prompt
**Graceful degradation on CW failure:**
If `get_ticket_context()` fails, the session still starts:
- `session.psa_ticket_id` is set (so we know which ticket to push docs to later)
- `session.psa_connection_id` is set
- `session.ticket_data` is null or minimal (just the ticket ID)
- The engineer's free-text intake (which may include pasted ticket notes) is used as the sole context
- A warning is returned in the response: `"psa_context_status": "unavailable"` so the frontend can show "Couldn't pull ticket details — ConnectWise may be unavailable"
**IMPORTANT — Reuse existing infrastructure:**
The ConnectWise provider at `services/psa/connectwise/provider.py` already has `get_ticket_context()` which returns a `TicketContext` schema (defined in `schemas/psa_context.py`). And `services/psa/ticket_context.py` already has `format_ticket_context_for_prompt()` that converts a `TicketContext` into a structured text block for AI prompts. Both of these are battle-tested from the existing session/copilot system. Phase 2 should call them directly:
```python
from app.services.psa.ticket_context import format_ticket_context_for_prompt
from app.services.psa.registry import get_provider_for_connection
# In _process_ticket_intake():
try:
provider = await get_provider_for_connection(psa_connection_id, db)
ticket_context = await provider.get_ticket_context(int(psa_ticket_id), str(psa_connection_id))
ticket_prompt_block = format_ticket_context_for_prompt(ticket_context)
session.ticket_data = ticket_context.model_dump(mode="json")
psa_context_status = "loaded"
except Exception as e:
logger.warning(f"Failed to fetch ticket context: {e}")
ticket_prompt_block = None
psa_context_status = "unavailable"
```
**Modify `start_session()`** to detect `intake_type == 'psa_ticket'` and call `_process_ticket_intake()` before the normal flow. The ticket context gets injected into the system prompt alongside any matched flow context.
**Key detail:** The engineer may also type additional context alongside the ticket pull (e.g., "Ticket #12345 — user called back and said it's also affecting their second monitor"). The intake content should merge both sources.
**Verification:** Start a session with `intake_type: "psa_ticket"` and a valid ticket ID. Verify FlowPilot's first question references the ticket content. Check `session.ticket_data` is populated. Also test with a bad connection — verify session still starts with a warning.
```
git commit -m "feat(ai-session): add PSA ticket intake to FlowPilot Engine"
```
### Task 2: Add ticket picker to FlowPilot intake screen
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotIntake.tsx`
**What to add:**
The "Pull from Ticket" button (currently disabled from Phase 1) becomes active when the user's account has a PSA connection configured.
**IMPORTANT — TicketPickerModal adaptation:** The existing `TicketPickerModal` at `src/components/session/TicketPickerModal.tsx` was built for legacy sessions — it requires a `sessionId` prop and calls `sessionPsaApi.linkTicket()` internally. For the FlowPilot intake screen, you need to either:
- (a) Create a new `FlowPilotTicketPicker` component that reuses the search/display logic but returns the selected ticket data to the parent instead of calling the link API, or
- (b) Refactor `TicketPickerModal` to accept an `onSelect` callback prop as an alternative to `sessionId`, making it usable in both contexts
Option (b) is preferred since it avoids code duplication. Add an `onSelect?: (ticketId: string, ticket: PSATicketInfo) => void` prop. When provided, the modal calls `onSelect` instead of the internal link API. The existing legacy usage passes `sessionId` + `onLinked` as before (no breaking change).
On click, open the adapted `TicketPickerModal`. When a ticket is selected:
1. The ticket summary populates the intake area as a styled ticket card (showing ticket #, summary, client name, priority badge)
2. An additional textarea appears below for "Add context" — optional free text the engineer can add
3. The intake type switches to `psa_ticket` (or `combined` if they also add text)
4. On submit, `createAISession()` is called with `intake_type: "psa_ticket"`, `psa_ticket_id`, `psa_connection_id`, and `intake_content` containing both the ticket reference and any additional text
**Manual ticket entry fallback:** If the ticket picker fails to connect to CW, or the engineer prefers, they can also manually type a ticket number and paste relevant notes into the free-text area. This still sets `intake_type: "psa_ticket"` with the ticket number, but `psa_connection_id` triggers a context fetch attempt on the backend (which may gracefully fail per Task 1).
**UX details:**
- Check for active PSA connections via existing `useTicketContext` hook or the integrations API
- If no PSA connection exists, the "Pull from Ticket" button shows a tooltip: "Connect your PSA in Settings → Integrations"
- The ticket card should match the existing `TicketContextPanel` styling — dark glass card with cyan accent border, ticket number prominent
- After ticket selection, "Start Session" button text changes to "Start Session with Ticket #12345"
- If CW fetch fails, show toast: "Couldn't reach ConnectWise — you can still type the ticket details manually"
**Verification:** Open the FlowPilot intake. Click "Pull from Ticket". Search for a ticket in ConnectWise. Select it. See the ticket card appear. Add optional context. Submit. Verify the session starts with ticket data.
```
git commit -m "feat(ai-session): add PSA ticket picker to FlowPilot intake"
```
### Task 3: Display ticket context in active session sidebar
**Files:**
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Create: `frontend/src/components/flowpilot/SessionTicketCard.tsx`
**What to add:**
When the active session has `psa_ticket_id` set, show a `SessionTicketCard` in the right sidebar above the confidence indicator. This card shows:
- Ticket # (clickable — opens ConnectWise ticket in new tab if URL is available)
- Ticket summary
- Client name
- Priority badge (color-coded)
- Status badge
- Config items list (if any)
If `ticket_data` is minimal (CW was unavailable at intake), show a simplified card with just the ticket number and a "Refresh from CW" button that attempts to pull context again.
Reuse styling patterns from the existing `TicketContextPanel` and `TicketLinkIndicator` components.
**Verification:** Start a ticket-based session. See the ticket card in the sidebar with all relevant info.
```
git commit -m "feat(ai-session): display ticket context in FlowPilot session sidebar"
```
---
## Slice 2: Auto Documentation Push to PSA
### Task 4: Build PSA documentation push service
**Files:**
- Create: `backend/app/services/psa_documentation_service.py`
**Architecture:**
This service takes a completed `AISession` and pushes structured documentation back to ConnectWise. It handles three operations:
1. **Internal Note:** Full diagnostic trail posted as an internal note on the ticket
2. **Time Entry:** Auto-create a time entry with the session duration (if CW member mapping exists)
3. **Status Update:** Optionally update ticket status based on contextual selection at resolution time
**Internal note format:**
```
═══ FlowPilot Session Documentation ═══
Session: {session_id}
Engineer: {user.display_name}
Date: {resolved_at}
Started: {created_at}
Ended: {resolved_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
Domain: {problem_domain}
── Diagnosis Path ──
1. [Question] {context_message}
→ Response: {selected_option or free_text_input}
2. [Action] {content description}
→ Result: {action_result summary}
3. [Question] {context_message}
→ Response: {selected_option}
... (all steps)
── Resolution ──
{resolution_summary}
{resolution_action}
── AI Confidence ──
Final confidence: {confidence_tier} ({confidence_score})
Matched flow: {matched_flow_name or "None - new discovery"}
── Session Timing ──
Start: {created_at formatted}
End: {resolved_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**IMPORTANT — Always include timing:** The "Session Timing" section is always present in the note, even when a time entry can't be created (missing member mapping). This ensures the time data is always on the ticket for manual entry.
**For escalations, the format changes:**
```
═══ FlowPilot Escalation Documentation ═══
Session: {session_id}
Escalated by: {user.display_name}
Escalated to: {escalated_to.display_name or "Unassigned"}
Date: {resolved_at}
Started: {created_at}
Duration: {duration_display}
── Problem ──
{problem_summary}
── Work Completed ──
{numbered list of all steps taken}
── Escalation Reason ──
{escalation_reason}
── Remaining Hypotheses ──
{from escalation_package.hypotheses}
── Suggested Next Steps ──
{from escalation_package.suggestions}
── Session Timing ──
Start: {created_at formatted}
Escalated: {escalated_at formatted}
Total: {duration_display}
Generated by ResolutionFlow FlowPilot
```
**Key implementation details:**
- Use the existing PSA provider abstraction (`services/psa/base.py``post_note()`)
- **PsaPostLog FK issue:** The existing `PsaPostLog` model has `ForeignKey("sessions.id")` pointing to old sessions, NOT `ai_sessions`. You must add an `ai_session_id` nullable UUID FK column to `PsaPostLog` (via migration) so it can reference AI sessions. Keep the original `session_id` column for backward compatibility — make it nullable if it isn't already.
- **Time entry method missing:** The PSA base class and ConnectWise provider do NOT currently have a `create_time_entry()` method. You must add: (1) `async def create_time_entry(ticket_id, member_id, hours, notes, work_type)` to `services/psa/base.py` as an abstract method, (2) implement it in `services/psa/connectwise/provider.py` using the CW `POST /time/entries` endpoint, (3) add a `PSATimeEntry` type to `services/psa/types.py`
- **Missing member mapping handling:** Before creating a time entry, look up the engineer's CW member ID via `PsaMemberMapping`. If no mapping exists: skip the time entry, include a `member_mapping_warning` in the response ("Map your CW account in Settings → Integrations to enable auto-logged time entries"). The note text always includes timing regardless.
- Use `apply_redaction_to_text()` from `services/redaction_service.py` to scrub passwords and sensitive data before pushing to ConnectWise
- Time entry calculation: `session.resolved_at - session.created_at`, rounded to nearest 15 minutes (configurable via `flowpilot_settings`)
- **Automatic retry on failure:** If the PSA push fails, create a `PsaPostLog` entry with `status='pending_retry'`. APScheduler job runs every 5 minutes, retries failed pushes up to 3 times with exponential backoff (5min, 15min, 45min). After 3 failures, status becomes `failed` and the frontend shows a manual "Retry" button.
- The documentation text should be plain text (ConnectWise notes don't support markdown well)
**Verification:** Resolve an AI session that has a linked ticket. Check ConnectWise — verify the internal note appeared on the ticket with the full diagnostic trail including timing. Verify a time entry was created (if member mapped). Check `psa_post_logs` table for the audit record.
```
git commit -m "feat(ai-session): add PSA documentation push service"
```
### Task 5: Wire documentation push into session resolution/escalation + background retry
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Create: `backend/app/services/psa_retry_scheduler.py`
**What to add:**
In the `resolve_session()` and `escalate_session()` methods, after generating documentation, check if the session has a `psa_ticket_id` and `psa_connection_id`. If so, call `psa_documentation_service.push_documentation()`.
**Flow:**
1. Engineer clicks Resolve → `POST /ai-sessions/{id}/resolve`
2. `flowpilot_engine.resolve_session()` generates documentation (existing)
3. **New:** If session has PSA link, call `psa_documentation_service.push_documentation(session, documentation)`
4. Push runs async — don't block the response
5. Return `SessionCloseResponse` with new fields: `psa_push_status`, `member_mapping_warning`
Same flow for escalation.
**Background retry scheduler (`psa_retry_scheduler.py`):**
APScheduler job that runs every 5 minutes:
1. Query `PsaPostLog` for entries with `status='pending_retry'` and `retry_count < 3`
2. For each, attempt the push again via `psa_documentation_service`
3. On success: update `status='sent'`
4. On failure: increment `retry_count`, set next retry with exponential backoff
5. After 3 failures: set `status='failed'`
Register the scheduler in the FastAPI lifespan (follows existing APScheduler pattern for maintenance flows).
**Add to response schemas:**
Edit `backend/app/schemas/ai_session.py` — add PSA fields to `SessionCloseResponse`:
```python
class SessionCloseResponse(BaseModel):
session_id: UUID
status: str
documentation: SessionDocumentation
psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed
psa_push_error: str | None = None
member_mapping_warning: str | None = None # Set when time entry skipped due to missing mapping
```
**Add manual retry endpoint:**
```
POST /api/v1/ai-sessions/{id}/retry-psa-push
```
Only callable when the session's latest `PsaPostLog` entry has `status='failed'`. Resets to `pending_retry` and triggers an immediate push attempt.
**Verification:** Resolve a ticket-linked session. Verify the response includes `psa_push_status: "sent"`. Check ConnectWise for the note. Resolve a session without a ticket — verify `psa_push_status: "no_psa"`. Test with a user who has no CW member mapping — verify `member_mapping_warning` is present and note still includes timing.
```
git commit -m "feat(ai-session): wire PSA documentation push into resolve/escalate with auto-retry"
```
### Task 6: Show PSA push status in frontend
**Files:**
- Edit: `frontend/src/components/flowpilot/SessionDocView.tsx`
- Edit: `frontend/src/types/ai-session.ts`
**What to add:**
After resolution/escalation, the documentation view now shows a PSA sync indicator:
- **"sent":** Green checkmark + "Documentation pushed to ticket #{ticket_id}"
- **"pending_retry":** Amber clock icon + "Documentation queued for push — will sync shortly"
- **"failed":** Red warning + "Failed to push to ticket — {error}" with a "Retry" button that calls `POST /ai-sessions/{id}/retry-psa-push`
- **"no_psa":** No indicator shown (session wasn't linked to a ticket)
If `member_mapping_warning` is present, show an info banner: "Time entry was not created — [Map your CW account](link to settings) to enable auto-logged time. Session timing is included in the ticket note."
Update the TypeScript types to include `psa_push_status`, `psa_push_error`, and `member_mapping_warning` on `SessionCloseResponse`.
**Verification:** Resolve a ticket-linked session. See "Documentation pushed to ticket #12345" in the documentation view. Test retry button with a simulated failure.
```
git commit -m "feat(ai-session): show PSA push status in documentation view"
```
---
## Slice 3: Session Pause/Resume & Escalation Handoff
### Task 7: Session pause/resume for same engineer
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**What to add:**
Engineers need to pause a session and come back later (lunch break, waiting for info, browser crash recovery).
**New endpoint — Pause session:**
```
POST /api/v1/ai-sessions/{id}/pause
```
Flow:
1. Verify session is `active` and belongs to current user
2. Set `session.status = "paused"`, `session.paused_at = utcnow()`
3. Return updated session
**New endpoint — Resume own paused session:**
```
POST /api/v1/ai-sessions/{id}/resume
```
Flow:
1. Verify session is `paused` and belongs to current user
2. Set `session.status = "active"`, clear `paused_at`
3. Return the session with all existing steps (engineer picks up exactly where they left off)
4. No briefing step needed — it's the same engineer
**Browser crash recovery:**
Sessions in `active` status should be resumable by navigating back to `/pilot/{sessionId}`. The frontend should detect an existing active session and restore it (conversation history is already in `conversation_messages` JSONB). This is mostly a frontend concern — the backend already stores all state.
**Verification:** Start a session, progress 3 steps. Pause it. Navigate away. Come back. Resume. Verify you're back at step 3 with full context. Also test: close the browser tab while in an active session, reopen, navigate to session — verify it loads correctly.
```
git commit -m "feat(ai-session): add session pause/resume for same engineer"
```
### Task 8: Build escalation handoff backend
**Files:**
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/schemas/ai_session.py`
**Session status lifecycle (updated from Phase 1):**
```
active → paused (same engineer pause)
paused → active (same engineer resume)
active → requesting_escalation (engineer requests escalation)
requesting_escalation → active (another engineer picks it up)
active → resolved (session completed)
active → escalated (escalation completed — terminal, session was handed off and resolved by another engineer)
requesting_escalation → escalated (escalation expired or cancelled — terminal)
```
**Key ownership rule:** `session.user_id` ALWAYS stays as Engineer A (the originator). When Engineer B picks up the session, we track them via a new `current_handler_id` field (or in the `escalation_package` JSONB). Both engineers see the session in their history — A sees "I escalated this" and B sees "I picked this up."
**Modify the existing `escalate_session()` in `flowpilot_engine.py`:**
1. Change `session.status = "escalated"``session.status = "requesting_escalation"`
2. Do NOT set `session.resolved_at` yet (session isn't done — it's waiting for pickup)
3. Store `session.escalation_package["original_user_id"] = str(user_id)`
4. **Block self-escalation:** If `escalated_to_id == current_user.id`, return 400 error
**Enhance `_build_escalation_package()`:**
The existing Phase 1 implementation builds a basic package with `problem_summary`, `steps_tried`, and `escalation_reason`. Enhance it to also include:
- `remaining_hypotheses`: Make a quick LLM call (haiku-tier via `AI_MODEL_TIERS["fast"]`) asking: "Based on this diagnostic conversation, what are the most likely remaining causes that haven't been ruled out?" Pass the conversation_messages as context.
- `suggested_next_steps`: From the same LLM call: "What should the next engineer try first?"
- `steps_ruled_out`: Walk the steps and identify options that were tested and failed
- `environment_context`: Extract any environment-specific info mentioned during the session (server names, IP addresses, software versions, etc.)
- `original_user_id`: The engineer who escalated (for attribution in the briefing)
This LLM call should use the fast model since it's a summarization task, not a diagnostic one. If the call fails, fall back to the basic package without hypotheses/suggestions — don't block the escalation.
**New endpoint — Pick up escalated session:**
```
POST /api/v1/ai-sessions/{id}/pickup
```
Request body:
```python
class PickupSessionRequest(BaseModel):
"""Pick up an escalated session as a new engineer."""
resume_mode: str = "continue" # "continue" or "fresh"
additional_context: str | None = None # New info or question from the receiving engineer
```
**Pickup flow:**
1. Verify session status is `requesting_escalation`
2. Verify the current user has permission (same team) and is NOT the original engineer
3. Track the new handler (add to `escalation_package["picked_up_by"] = str(user_id)`, `escalation_package["picked_up_at"] = utcnow()`)
4. Set `session.status = "active"`
5. Generate a "briefing step" — a special step that summarizes everything for the new engineer:
- "Here's what {original_engineer} found so far: ..."
- "They ruled out X, Y, Z"
- "Remaining hypotheses: A, B"
- "Suggested next steps: ..."
6. Based on `resume_mode`:
- `"continue"`: Generate the next diagnostic step as usual (picks up where A left off)
- `"fresh"`: Use `additional_context` as new input, but FlowPilot's system prompt includes all of A's work so it won't repeat steps
7. Return the briefing step + next step
**New endpoint — List sessions requesting escalation for team:**
```
GET /api/v1/ai-sessions/escalation-queue
```
Returns sessions with `status = "requesting_escalation"` for the current user's team, sorted by most recent. This is the "pickup queue" for escalated tickets. Includes: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count, assigned-to (if specified).
**Verification:** Start a session, progress 3-4 steps, escalate with a reason. Verify session is `requesting_escalation`. Log in as another user on the same team. Hit `/ai-sessions/escalation-queue`. See the session. Pick it up with `resume_mode: "continue"`. Verify the briefing step accurately summarizes prior work. Continue diagnosis. Also test `resume_mode: "fresh"` with additional context.
```
git commit -m "feat(ai-session): add escalation handoff backend with pickup flow"
```
### Task 9: Escalation handoff frontend + in-app notifications
**Files:**
- Create: `frontend/src/components/flowpilot/EscalateModal.tsx`
- Create: `frontend/src/components/flowpilot/EscalationQueue.tsx`
- Create: `frontend/src/components/flowpilot/SessionBriefing.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotActionBar.tsx`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
- Edit: `frontend/src/hooks/useFlowPilotSession.ts`
- Edit: `frontend/src/api/aiSessions.ts`
- Edit: `frontend/src/types/ai-session.ts`
- Edit: `frontend/src/components/layout/Sidebar.tsx` (or equivalent nav component)
- Edit: `frontend/src/router.tsx`
**EscalateModal:**
When the engineer clicks "Escalate" in the action bar, this modal opens:
- Textarea: "Why are you escalating?" (required)
- Dropdown: "Assign to" — list of team members (optional, defaults to unassigned)
- Summary card: auto-generated preview of the escalation package (steps taken, hypotheses remaining)
- "Escalate & Update Ticket" button (if PSA linked) / "Escalate" button (if not)
- **Self-escalation blocked:** Current user excluded from the "Assign to" dropdown
**EscalationQueue:**
New component accessible from **both** the sidebar nav and as a tab in session history.
**Sidebar nav item:** "Escalations" with a badge showing count of sessions in `requesting_escalation` status for the user's team. Badge uses amber-400 color. Positioned below "Sessions" in the nav.
**Session history tab:** New tab "Escalated" alongside existing tabs. Shows the same queue content.
Queue content:
- Card for each session in `requesting_escalation`: problem summary, escalation reason, who escalated, when, ticket # (if linked), step count
- "Pick Up" button on each card
- Sort by most recent
- Filter by: assigned to me, unassigned, all
**SessionBriefing:**
When an engineer picks up an escalated session, the first thing they see is a styled briefing card (distinct from normal step cards — use an amber/purple accent border to distinguish from regular cyan steps):
- "Escalation from {original_engineer}"
- Problem summary
- Steps already taken (collapsed list, expandable)
- What was ruled out
- Remaining hypotheses
- Suggested next steps
- Two action buttons:
- **"Continue Where They Left Off"** → calls pickup with `resume_mode: "continue"`, proceeds to FlowPilot's next question
- **"Start Fresh With Context"** → shows a textarea for the engineer to type their own input/question, then calls pickup with `resume_mode: "fresh"` and `additional_context`
**Pause/Resume UI (from Task 7):**
- Add "Pause" button to `FlowPilotActionBar` (alongside Resolve and Escalate)
- Paused sessions show in session history with a "Paused" badge
- Clicking a paused session resumes it automatically (or shows a "Resume" button)
- On page load, if navigating to `/pilot/{sessionId}` and session is `active`, restore the full conversation (browser crash recovery)
**API client additions:**
Add to the existing `aiSessionsApi` object in `src/api/aiSessions.ts` (follow the same pattern as existing methods):
```typescript
// Add to aiSessionsApi object:
async pauseSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/pause`
)
return response.data
},
async resumeSession(sessionId: string): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/resume`
)
return response.data
},
async pickupSession(sessionId: string, data: { resume_mode: string; additional_context?: string }): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string }> {
const response = await apiClient.post<{ psa_push_status: string }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
```
**Hook updates:**
Add `pauseSession`, `resumeSession`, `pickupSession`, `escalationQueue`, `linkTicket`, `retryPsaPush` to `useFlowPilotSession`.
**Router updates:**
- Add route for the escalation queue page (e.g., `/escalations`)
- Ensure `/pilot/{sessionId}` handles all session states (active, paused, requesting_escalation)
**Verification:** Full escalation flow — Engineer A starts session, progresses, escalates with reason. Engineer B sees it in the sidebar queue (badge count), picks it up via "Continue Where They Left Off", sees the briefing, continues diagnosis, resolves. Also test: Engineer B picks up via "Start Fresh With Context" with their own input. Also test pause/resume for same engineer.
```
git commit -m "feat(ai-session): add escalation handoff and pause/resume frontend"
```
---
## Slice 4: Session-to-Ticket Linking for Existing Sessions
### Task 10: Link an in-progress session to a ticket retroactively
**Files:**
- Edit: `backend/app/api/endpoints/ai_sessions.py`
- Edit: `backend/app/services/flowpilot_engine.py`
- Edit: `frontend/src/components/flowpilot/FlowPilotSession.tsx`
**What to add:**
Sometimes an engineer starts a session with free-text intake and then realizes "oh, this is ticket #12345." They should be able to link a ticket mid-session.
**New endpoint:**
```
POST /api/v1/ai-sessions/{id}/link-ticket
```
Request:
```python
class LinkTicketRequest(BaseModel):
psa_ticket_id: str
psa_connection_id: UUID
```
**Flow:**
1. Fetch ticket data from ConnectWise (graceful failure — if CW is down, still store the ticket ID for later doc push)
2. Update `session.psa_ticket_id`, `session.psa_connection_id`, `session.ticket_data`
3. **Inject ticket context into FlowPilot's system prompt** for subsequent steps — append the formatted ticket context to `session.conversation_messages` system prompt. FlowPilot will naturally acknowledge the new context in its next response.
4. Return updated session data
**Frontend:**
Add a "Link Ticket" button in the session sidebar (where the ticket card would be, if there isn't one). Opens the adapted `TicketPickerModal` (with `onSelect` prop from Task 2). On selection, calls `linkTicket()` and the `SessionTicketCard` appears in the sidebar.
**Verification:** Start a free-text session. Progress a few steps. Click "Link Ticket". Select a ticket. Verify ticket card appears in sidebar. Continue diagnosis — verify FlowPilot's next response acknowledges the ticket context. Resolve. Verify documentation pushes to the linked ticket.
```
git commit -m "feat(ai-session): add mid-session ticket linking with context injection"
```
---
## Slice 5: Configuration & Settings
### Task 11: FlowPilot PSA settings
**Files:**
- Edit: `frontend/src/pages/account/IntegrationsPage.tsx` (or create a new section)
- Edit: `backend/app/models/psa_connection.py` (add fields if needed)
- Edit: `backend/app/api/endpoints/integrations.py` (settings CRUD)
**What to add:**
Under the existing PSA integrations settings, add a "FlowPilot Settings" section:
- **Auto-push documentation:** Toggle (default: on) — automatically push session documentation to linked tickets on resolution
- **Auto-create time entry:** Toggle (default: on) — automatically create a time entry when resolving (requires CW member mapping)
- **Time rounding:** Dropdown — "Nearest 15 minutes" (default), "Nearest 30 minutes", "Exact", "Don't create time entries"
- **Default note visibility:** Dropdown — "Internal only" (default), "Internal and external"
- **Include diagnostic steps in notes:** Toggle (default: on) — if off, only push the summary, not the full step trail
- **Prompt for ticket status on resolution:** Toggle (default: off) — when on, engineer sees a status dropdown at resolution time, populated dynamically from the linked ticket's board statuses via `get_ticket_statuses(board_id)`. When off, ticket status is not changed.
- **Prompt for ticket status on escalation:** Toggle (default: off) — same as above but for escalation
**Note on status dropdowns:** These are NOT global dropdowns in settings. The setting is just a toggle for whether the engineer is prompted. The actual status options are pulled dynamically at resolution/escalation time based on the specific ticket's board (using the existing `get_ticket_statuses(board_id)` method). This is board-agnostic — works correctly regardless of which CW board the ticket is on.
These settings should be stored on the `PsaConnection` model as a `flowpilot_settings` JSONB column (add via migration if needed).
**Verification:** Navigate to integrations settings. See FlowPilot settings section. Toggle settings. Resolve a session with "prompt for status" enabled — verify the status dropdown shows the correct statuses for that ticket's board. Verify the documentation push respects all configured settings.
```
git commit -m "feat(ai-session): add FlowPilot PSA configuration settings"
```
---
## Summary of All New/Modified Files
### Backend — New
```
app/services/psa_documentation_service.py # Documentation push to PSA
app/services/psa_retry_scheduler.py # APScheduler job for retrying failed PSA pushes
```
### Backend — Modified
```
app/services/flowpilot_engine.py # PSA ticket intake, pause/resume, enhanced escalation, pickup
app/api/endpoints/ai_sessions.py # Pause, resume, pickup, escalation queue, link-ticket, retry-push endpoints
app/schemas/ai_session.py # New schemas: PickupSessionRequest, LinkTicketRequest, psa_push_status, member_mapping_warning
app/models/psa_connection.py # Add flowpilot_settings JSONB column
app/models/psa_post_log.py # Add ai_session_id FK, make session_id nullable, add retry_count
app/services/psa/base.py # Add abstract create_time_entry() method
app/services/psa/types.py # Add PSATimeEntry type
app/services/psa/connectwise/provider.py # Implement create_time_entry() for CW API
app/components/session/TicketPickerModal.tsx # Add onSelect callback prop for dual-mode usage
alembic/versions/xxx_phase2_psa_flowpilot.py # Migration: flowpilot_settings, psa_post_log changes
```
### Frontend — New
```
src/components/flowpilot/EscalateModal.tsx # Enhanced escalation dialog with team member dropdown
src/components/flowpilot/EscalationQueue.tsx # Pickup queue for escalated sessions
src/components/flowpilot/SessionBriefing.tsx # Handoff briefing card with continue/fresh options
src/components/flowpilot/SessionTicketCard.tsx # Ticket info in session sidebar
```
### Frontend — Modified
```
src/components/flowpilot/FlowPilotIntake.tsx # Ticket picker integration, manual fallback, PSA connection check
src/components/flowpilot/FlowPilotSession.tsx # Ticket card in sidebar, link ticket button, pause/resume
src/components/flowpilot/FlowPilotActionBar.tsx # Pause button, escalate opens enhanced modal
src/components/flowpilot/SessionDocView.tsx # PSA push status indicator, retry button, member mapping warning
src/components/session/TicketPickerModal.tsx # Add onSelect prop for FlowPilot intake usage
src/components/layout/Sidebar.tsx # Escalation queue nav item with badge count
src/hooks/useFlowPilotSession.ts # Pause, resume, pickup, linkTicket, escalationQueue, retryPsaPush
src/api/aiSessions.ts # New API functions (follow aiSessionsApi object pattern)
src/types/ai-session.ts # New types: psa_push_status, PickupSessionRequest, etc.
src/pages/account/IntegrationsPage.tsx # FlowPilot PSA settings section
src/router.tsx # Escalation queue route
```
---
## Database Changes
**Migration:** This phase requires a single migration with multiple changes:
```python
# 1. Add flowpilot_settings to psa_connections
op.add_column('psa_connections', sa.Column(
'flowpilot_settings',
sa.dialects.postgresql.JSONB(),
nullable=True,
server_default='{}',
comment='FlowPilot-specific settings: auto_push, time_rounding, note_visibility, etc.'
))
# 2. Add ai_session_id FK to psa_post_log (existing table points to old sessions only)
op.add_column('psa_post_log', sa.Column(
'ai_session_id',
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey('ai_sessions.id', ondelete='CASCADE'),
nullable=True,
comment='FK to AI sessions (Phase 2). Original session_id FK remains for legacy sessions.'
))
op.create_index('ix_psa_post_log_ai_session_id', 'psa_post_log', ['ai_session_id'])
# 3. Make original session_id nullable (was NOT NULL — legacy sessions only)
op.alter_column('psa_post_log', 'session_id', nullable=True)
# 4. Add retry_count to psa_post_log for automatic retries
op.add_column('psa_post_log', sa.Column(
'retry_count',
sa.Integer(),
nullable=False,
server_default='0',
comment='Number of retry attempts for failed PSA pushes'
))
op.add_column('psa_post_log', sa.Column(
'next_retry_at',
sa.DateTime(timezone=True),
nullable=True,
comment='When to attempt the next retry'
))
```
**Also update `PsaPostLog` model** (`app/models/psa_post_log.py`): Add the `ai_session_id` mapped column and relationship. Make `session_id` `Optional`. Add `retry_count` and `next_retry_at`.
**Also update `PsaConnection` model** (`app/models/psa_connection.py`): Add the `flowpilot_settings` JSONB mapped column.
**Also update PSA abstraction layer:**
- `services/psa/types.py`: Add `PSATimeEntry` model
- `services/psa/base.py`: Add abstract `create_time_entry()` method
- `services/psa/connectwise/provider.py`: Implement `create_time_entry()` using CW `POST /time/entries`
**Run migration:**
```bash
cd /projects/patherly/backend
DATABASE_URL=postgresql://postgres:postgres@resolutionflow_postgres:5432/resolutionflow \
venv/bin/alembic upgrade head
```
---
## Testing Strategy
All tests use mocked ConnectWise responses based on the OpenAPI spec (no CW sandbox available yet). Mock shapes should match `docs/connectwise/connectwise-psa-resolutionflow-reference.json`.
### Backend Unit Tests
**Files:** `backend/tests/test_psa_documentation_service.py`
- Test documentation formatting for resolved sessions (verify timing section always present)
- Test documentation formatting for escalated sessions
- Test password redaction in documentation
- Test time entry calculation (rounding logic for 15min, 30min, exact)
- Test PSA push with mock ConnectWise client
- Test missing member mapping — verify warning returned and note still includes timing
- Test retry logic — verify exponential backoff scheduling
**Files:** `backend/tests/test_escalation_handoff.py`
- Test escalation package generation (including LLM-generated hypotheses)
- Test self-escalation blocked (400 error)
- Test session pickup flow — "continue" mode (new engineer, briefing step)
- Test session pickup flow — "fresh" mode (new engineer provides own context)
- Test ownership preserved (session.user_id stays as Engineer A)
- Test permission enforcement (can't pick up session from another team)
- Test pause/resume for same engineer
**Files:** `backend/tests/test_ai_sessions_psa.py`
- Full flow: create ticket-based session → diagnose → resolve → verify PSA push with timing
- Full flow: create session → escalate → pickup by another user → resolve
- Test mid-session ticket linking with context injection
- Test PSA push failure → automatic retry → eventual success
- Test PSA push failure → exhaust retries → manual retry button
- Test graceful degradation when CW API is unavailable at intake
### Frontend Manual Testing
1. Start a session from a ticket — verify FlowPilot references ticket context
2. Start a session with CW unavailable — verify manual fallback works
3. Resolve a ticket session — verify ConnectWise shows the note with timing
4. Resolve without CW member mapping — verify warning shown, timing in notes
5. Start a free-text session → link ticket mid-session → verify FlowPilot acknowledges context → resolve → verify push
6. Pause a session → navigate away → come back → resume → verify full context preserved
7. Close browser tab during active session → reopen → navigate to session → verify recovery
8. Full escalation: Engineer A escalates → Engineer B sees badge in sidebar → picks up via "Continue" → sees briefing → resolves
9. Full escalation: Engineer B picks up via "Start Fresh" with own context → FlowPilot doesn't repeat A's steps
10. Verify Engineer A still sees the session in their history after B picks it up
11. Test PSA settings — toggle options, verify behavior changes
12. Test ticket status prompt at resolution — verify correct statuses shown for that ticket's board
---
## What Comes Next (Phase 3 — NOT in scope here)
For context only — do NOT implement these in Phase 2:
- **Knowledge Flywheel:** Post-session flow proposal generation
- **Review Queue:** UI for approving AI-generated flow proposals
- **Flow Editor as curation tool:** Repurpose for reviewing AI-generated flows
- **In-session Script Generator:** FlowPilot invokes script generation contextually
- **Knowledge gap detection:** Track free-text escapes, high escalation categories
- **Team analytics:** MTTR, resolution rates, knowledge coverage
- **Escalation notifications:** Push notifications or email alerts for escalation queue (Phase 2 has in-app badge only)

View File

@@ -10,6 +10,7 @@ import type {
SessionDocumentation,
AISessionSummary,
AISessionDetail,
PickupSessionRequest,
} from '@/types/ai-session'
export const aiSessionsApi = {
@@ -62,6 +63,42 @@ export const aiSessionsApi = {
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
},
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string; psa_push_error: string | null }> {
const response = await apiClient.post<{ psa_push_status: string; psa_push_error: string | null }>(
`/ai-sessions/${sessionId}/retry-psa-push`
)
return response.data
},
async pauseSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
},
async resumeSession(sessionId: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
},
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,
data
)
return response.data
},
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
const response = await apiClient.post<AISessionDetail>(
`/ai-sessions/${sessionId}/link-ticket`,
data
)
return response.data
},
async getEscalationQueue(): Promise<AISessionSummary[]> {
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
return response.data
},
}
export default aiSessionsApi

View File

@@ -1,6 +1,6 @@
import { apiClient } from './client'
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult } from '@/types/integrations'
import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult, FlowpilotSettings } from '@/types/integrations'
export const integrationsApi = {
getConnection: () =>
@@ -27,6 +27,10 @@ export const integrationsApi = {
apiClient.post<PsaMemberMappingResponse[]>('/integrations/psa/member-mappings', mappings).then(r => r.data),
autoMatchMembers: () =>
apiClient.post<AutoMatchResult>('/integrations/psa/member-mappings/auto-match').then(r => r.data),
getFlowpilotSettings: (connectionId: string) =>
apiClient.get<FlowpilotSettings>(`/integrations/psa/connections/${connectionId}/flowpilot-settings`).then(r => r.data),
updateFlowpilotSettings: (connectionId: string, data: Partial<FlowpilotSettings>) =>
apiClient.put<FlowpilotSettings>(`/integrations/psa/connections/${connectionId}/flowpilot-settings`, data).then(r => r.data),
}
export const sessionPsaApi = {

View File

@@ -0,0 +1,82 @@
import { useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import type { EscalateSessionRequest } from '@/types/ai-session'
interface EscalateModalProps {
open: boolean
onClose: () => void
onEscalate: (data: EscalateSessionRequest) => Promise<unknown>
isProcessing: boolean
hasPsaTicket: boolean
}
export function EscalateModal({ open, onClose, onEscalate, isProcessing, hasPsaTicket }: EscalateModalProps) {
const [reason, setReason] = useState('')
const handleSubmit = async () => {
if (!reason.trim() || reason.trim().length < 5) return
await onEscalate({ escalation_reason: reason.trim() })
setReason('')
onClose()
}
const handleClose = () => {
if (!isProcessing) {
setReason('')
onClose()
}
}
return (
<Modal isOpen={open} onClose={handleClose} title="Escalate Session" size="sm">
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-xl border border-amber-400/20 bg-amber-400/5 p-3">
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">
This will mark the session as requesting escalation. Team members will see it in their escalation queue and can pick it up with full context.
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-foreground">
Why are you escalating?
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. I've exhausted all networking diagnostics and suspect this is a firewall policy issue that requires senior admin access..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={4}
autoFocus
/>
<p className="mt-1 text-[0.625rem] text-[#5a6170]">
Minimum 5 characters. This will be shown to the engineer who picks up.
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleClose}
disabled={isProcessing}
className="flex-1 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!reason.trim() || reason.trim().length < 5 || isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-amber-500/90 px-4 py-2.5 text-sm font-semibold text-[#101114] hover:bg-amber-500 active:scale-[0.97] disabled:opacity-40 transition-all"
>
{isProcessing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<AlertTriangle size={14} />
)}
{hasPsaTicket ? 'Escalate & Update Ticket' : 'Escalate'}
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
import { aiSessionsApi } from '@/api'
import type { AISessionSummary } from '@/types/ai-session'
interface EscalationQueueProps {
onPickup?: (sessionId: string) => void
}
export function EscalationQueue({ onPickup }: EscalationQueueProps) {
const navigate = useNavigate()
const [sessions, setSessions] = useState<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadQueue = async () => {
setIsLoading(true)
setError(null)
try {
const data = await aiSessionsApi.getEscalationQueue()
setSessions(data)
} catch {
setError('Failed to load escalation queue')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadQueue()
}, [])
const handlePickup = (sessionId: string) => {
if (onPickup) {
onPickup(sessionId)
} else {
navigate(`/pilot/${sessionId}?pickup=true`)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="py-12 text-center">
<p className="text-sm text-rose-400">{error}</p>
<button
onClick={loadQueue}
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Try again
</button>
</div>
)
}
if (sessions.length === 0) {
return (
<div className="py-12 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No sessions awaiting escalation</p>
<button
onClick={loadQueue}
className="mt-3 flex items-center gap-1.5 mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h3 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170]">
Awaiting pickup ({sessions.length})
</h3>
<button
onClick={loadQueue}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw size={10} />
Refresh
</button>
</div>
{sessions.map((session) => (
<div key={session.id} className="glass-card p-4 space-y-3">
<div>
<p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'}
</p>
{session.escalation_reason && (
<p className="mt-1 text-xs text-amber-400 line-clamp-2">
Reason: {session.escalation_reason}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && (
<span className="font-label rounded-md bg-primary/10 px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary">
{session.problem_domain}
</span>
)}
<span className="flex items-center gap-1">
<Hash size={10} />
{session.step_count} steps
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{session.psa_ticket_id && (
<span className="flex items-center gap-1 text-primary">
<Ticket size={10} />
#{session.psa_ticket_id}
</span>
)}
</div>
<button
onClick={() => handlePickup(session.id)}
className="w-full rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Pick Up Session
</button>
</div>
))}
</div>
)
}

View File

@@ -1,26 +1,30 @@
import { useState } from 'react'
import { CheckCircle2, ArrowUpRight } from 'lucide-react'
import { CheckCircle2, ArrowUpRight, Pause } from 'lucide-react'
import { EscalateModal } from './EscalateModal'
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
interface FlowPilotActionBarProps {
canResolve: boolean
canEscalate: boolean
isProcessing: boolean
hasPsaTicket?: boolean
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
}
export function FlowPilotActionBar({
canResolve,
canEscalate,
isProcessing,
hasPsaTicket = false,
onResolve,
onEscalate,
onPause,
}: FlowPilotActionBarProps) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [escalationReason, setEscalationReason] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleResolve = async () => {
@@ -34,14 +38,14 @@ export function FlowPilotActionBar({
}
}
const handleEscalate = async () => {
if (!escalationReason.trim() || escalationReason.length < 5) return
setSubmitting(true)
try {
await onEscalate({ escalation_reason: escalationReason })
setShowEscalate(false)
} finally {
setSubmitting(false)
const handlePause = async () => {
if (onPause) {
setSubmitting(true)
try {
await onPause()
} finally {
setSubmitting(false)
}
}
}
@@ -61,13 +65,23 @@ export function FlowPilotActionBar({
Resolve
</button>
<button
onClick={() => { setShowEscalate(true); setShowResolve(false) }}
onClick={() => setShowEscalate(true)}
disabled={!canEscalate || isProcessing}
className="flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/20 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<ArrowUpRight size={16} />
Escalate
</button>
{onPause && (
<button
onClick={handlePause}
disabled={isProcessing || submitting}
className="flex items-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors ml-auto"
>
<Pause size={16} />
Pause
</button>
)}
</div>
{/* Resolve modal */}
@@ -104,37 +118,13 @@ export function FlowPilotActionBar({
)}
{/* Escalate modal */}
{showEscalate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-card-static w-full max-w-lg p-6">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Escalate Session</h3>
<p className="text-sm text-muted-foreground mb-4">Explain why this needs escalation. FlowPilot will package the context for the next engineer.</p>
<textarea
value={escalationReason}
onChange={(e) => setEscalationReason(e.target.value)}
placeholder="Why does this need to be escalated?"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={4}
autoFocus
/>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowEscalate(false)}
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleEscalate}
disabled={escalationReason.length < 5 || submitting}
className="rounded-lg bg-amber-500/20 border border-amber-500/30 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Escalating...' : 'Escalate Session'}
</button>
</div>
</div>
</div>
)}
<EscalateModal
open={showEscalate}
onClose={() => setShowEscalate(false)}
onEscalate={onEscalate}
isProcessing={isProcessing || submitting}
hasPsaTicket={hasPsaTicket}
/>
</>
)
}

View File

@@ -1,6 +1,9 @@
import { useState } from 'react'
import { Sparkles, FileText, Terminal } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Sparkles, FileText, Terminal, X, AlertTriangle } from 'lucide-react'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { integrationsApi } from '@/api/integrations'
import type { AISessionCreateRequest } from '@/types/ai-session'
import type { PSATicketInfo, PsaConnectionResponse } from '@/types/integrations'
interface FlowPilotIntakeProps {
onSubmit: (request: AISessionCreateRequest) => void
@@ -11,8 +14,67 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
const [text, setText] = useState('')
const [showLogs, setShowLogs] = useState(false)
const [logContent, setLogContent] = useState('')
const [showTicketPicker, setShowTicketPicker] = useState(false)
// PSA connection state
const [psaConnection, setPsaConnection] = useState<PsaConnectionResponse | null>(null)
const [psaChecked, setPsaChecked] = useState(false)
// Selected ticket state
const [selectedTicket, setSelectedTicket] = useState<PSATicketInfo | null>(null)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const [additionalContext, setAdditionalContext] = useState('')
// Check for PSA connection on mount
useEffect(() => {
let cancelled = false
integrationsApi.getConnection()
.then((conn) => {
if (!cancelled) {
setPsaConnection(conn && conn.is_active ? conn : null)
setPsaChecked(true)
}
})
.catch(() => {
if (!cancelled) setPsaChecked(true)
})
return () => { cancelled = true }
}, [])
const handleTicketSelected = (ticketId: string, ticket: PSATicketInfo) => {
setSelectedTicketId(ticketId)
setSelectedTicket(ticket)
setShowTicketPicker(false)
}
const handleClearTicket = () => {
setSelectedTicketId(null)
setSelectedTicket(null)
setAdditionalContext('')
}
const handleSubmit = () => {
// Ticket-based submission
if (selectedTicket && selectedTicketId && psaConnection) {
const intake_content: Record<string, unknown> = {
text: additionalContext.trim() || undefined,
ticket_data: {
summary: selectedTicket.summary,
company: selectedTicket.company_name,
priority: selectedTicket.priority_name,
},
}
onSubmit({
intake_type: 'psa_ticket',
intake_content,
psa_ticket_id: selectedTicketId,
psa_connection_id: psaConnection.id,
})
return
}
// Free-text / log submission
if (!text.trim() && !logContent.trim()) return
const intake_content: Record<string, unknown> = {}
@@ -26,7 +88,7 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
onSubmit({ intake_type, intake_content })
}
const hasContent = text.trim() || logContent.trim()
const hasContent = text.trim() || logContent.trim() || selectedTicket
if (isLoading) {
return (
@@ -42,6 +104,10 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
)
}
const submitLabel = selectedTicket && selectedTicketId
? `Start Session with Ticket #${selectedTicketId}`
: 'Start Session'
return (
<div className="flex items-start justify-center pt-[10vh]">
<div className="w-full max-w-2xl">
@@ -50,46 +116,110 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
What are you troubleshooting?
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Describe the issue, paste an error message, or paste log output
Describe the issue, paste an error message, or pull context from a ticket
</p>
</div>
<div className="glass-card-static p-5 space-y-4">
{/* Main text area */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={5}
autoFocus
/>
{/* Selected ticket card */}
{selectedTicket && selectedTicketId && (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
<span className="text-primary">#{selectedTicketId}</span>
{' — '}
{selectedTicket.summary}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
{selectedTicket.company_name && <span>{selectedTicket.company_name}</span>}
{selectedTicket.priority_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.priority_name}</span>
</>
)}
{selectedTicket.status_name && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{selectedTicket.status_name}</span>
</>
)}
</div>
</div>
<button
onClick={handleClearTicket}
className="ml-2 rounded-md p-1 text-muted-foreground hover:bg-white/[0.06] hover:text-foreground transition-colors"
>
<X size={14} />
</button>
</div>
{/* Additional context textarea */}
<textarea
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder="Add extra context (optional) — e.g. 'User called back and said it's also affecting their second monitor'"
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={3}
/>
</div>
)}
{/* Main text area (hidden when ticket is selected) */}
{!selectedTicket && (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={5}
autoFocus
/>
)}
{/* Input type toggles */}
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogs(!showLogs)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
showLogs
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
}`}
>
<Terminal size={12} />
Paste Logs
</button>
<button
disabled
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed"
title="Coming in Phase 2"
>
<FileText size={12} />
Pull from Ticket
</button>
</div>
{!selectedTicket && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowLogs(!showLogs)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
showLogs
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
}`}
>
<Terminal size={12} />
Paste Logs
</button>
<button
onClick={() => {
if (psaConnection) {
setShowTicketPicker(true)
}
}}
disabled={!psaChecked || !psaConnection}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
psaConnection
? 'bg-card/50 text-muted-foreground border border-border hover:text-foreground hover:border-primary/20'
: 'bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed'
}`}
title={!psaConnection ? 'Connect your PSA in Settings → Integrations' : 'Search for a ConnectWise ticket'}
>
<FileText size={12} />
Pull from Ticket
</button>
{psaChecked && !psaConnection && (
<span className="flex items-center gap-1 text-[0.625rem] text-amber-400/80">
<AlertTriangle size={10} />
No PSA connected
</span>
)}
</div>
)}
{/* Log paste area */}
{showLogs && (
{!selectedTicket && showLogs && (
<textarea
value={logContent}
onChange={(e) => setLogContent(e.target.value)}
@@ -107,13 +237,20 @@ export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
<button
onClick={handleSubmit}
disabled={!hasContent}
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all"
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all whitespace-nowrap"
>
Start Session
{submitLabel}
</button>
</div>
</div>
</div>
{/* Ticket picker modal */}
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
onSelect={handleTicketSelected}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { Network, Clock, Hash } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Network, Clock, Hash, Play, Ticket } from 'lucide-react'
import type {
AISessionDetail,
AISessionStepResponse,
@@ -12,6 +12,11 @@ import { ConfidenceIndicator } from './ConfidenceIndicator'
import { FlowPilotStepCard } from './FlowPilotStepCard'
import { FlowPilotActionBar } from './FlowPilotActionBar'
import { SessionDocView } from './SessionDocView'
import { SessionTicketCard } from './SessionTicketCard'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { aiSessionsApi } from '@/api'
import { toast } from '@/lib/toast'
import type { PSATicketInfo } from '@/types/integrations'
interface FlowPilotSessionProps {
session: AISessionDetail
@@ -21,9 +26,14 @@ interface FlowPilotSessionProps {
canResolve: boolean
canEscalate: boolean
documentation: SessionDocumentation | null
psaPushStatus?: string | null
psaPushError?: string | null
memberMappingWarning?: string | null
onRespond: (response: StepResponseRequest) => void
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
onResume?: () => Promise<void>
onRate: (rating: number) => void
}
@@ -35,12 +45,55 @@ export function FlowPilotSession({
canResolve,
canEscalate,
documentation,
psaPushStatus,
psaPushError,
memberMappingWarning,
onRespond,
onResolve,
onEscalate,
onPause,
onResume,
onRate,
}: FlowPilotSessionProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [showTicketPicker, setShowTicketPicker] = useState(false)
const [linkingTicket, setLinkingTicket] = useState(false)
const handleLinkTicket = async (ticketId: string, _ticket: PSATicketInfo) => {
if (!session.psa_connection_id && !session.ticket_data) {
// Need a connection ID — try to get it from the integrations API
// For now, we'll need it passed in. This will work when ticket_data has it.
toast.error('No PSA connection available')
return
}
setLinkingTicket(true)
setShowTicketPicker(false)
try {
// We need the psa_connection_id. If the session doesn't have one,
// fetch it from the integrations API
let connectionId = session.psa_connection_id
if (!connectionId) {
const { integrationsApi } = await import('@/api/integrations')
const conn = await integrationsApi.getConnection()
if (!conn?.id) {
toast.error('No PSA connection configured')
return
}
connectionId = conn.id
}
await aiSessionsApi.linkTicket(session.id, {
psa_ticket_id: ticketId,
psa_connection_id: connectionId,
})
toast.success(`Linked to ticket #${ticketId}`)
// Reload session to get updated ticket_data
window.location.reload()
} catch {
toast.error('Failed to link ticket')
} finally {
setLinkingTicket(false)
}
}
// Auto-scroll to latest step
useEffect(() => {
@@ -60,6 +113,11 @@ export function FlowPilotSession({
documentation={documentation}
onRate={onRate}
currentRating={session.session_rating}
psaPushStatus={psaPushStatus}
psaPushError={psaPushError}
memberMappingWarning={memberMappingWarning}
sessionId={session.id}
ticketId={session.psa_ticket_id}
/>
</div>
</div>
@@ -91,6 +149,23 @@ export function FlowPilotSession({
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="space-y-4">
{/* Ticket context */}
{session.psa_ticket_id ? (
<SessionTicketCard
ticketId={session.psa_ticket_id}
ticketData={session.ticket_data as Record<string, unknown> | null}
/>
) : session.status === 'active' ? (
<button
onClick={() => setShowTicketPicker(true)}
disabled={linkingTicket}
className="w-full flex items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/30 transition-colors disabled:opacity-50"
>
<Ticket size={14} />
{linkingTicket ? 'Linking...' : 'Link Ticket'}
</button>
) : null}
{/* Problem summary */}
{session.problem_summary && (
<div>
@@ -162,10 +237,36 @@ export function FlowPilotSession({
canResolve={canResolve}
canEscalate={canEscalate}
isProcessing={isProcessing}
hasPsaTicket={!!session.psa_ticket_id}
onResolve={onResolve}
onEscalate={onEscalate}
onPause={onPause}
/>
)}
{/* Paused banner */}
{session.status === 'paused' && onResume && (
<div
className="flex items-center justify-between border-t px-5 py-3"
style={{ borderColor: 'var(--glass-border)', background: 'rgba(16, 17, 20, 0.8)', backdropFilter: 'blur(12px)' }}
>
<span className="text-sm text-muted-foreground">Session paused</span>
<button
onClick={onResume}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
<Play size={14} />
Resume Session
</button>
</div>
)}
{/* Ticket picker modal for mid-session linking */}
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
onSelect={handleLinkTicket}
/>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import { ArrowRight, ChevronDown, ChevronRight, MessageSquare } from 'lucide-react'
interface EscalationPackage {
original_user_id?: string
problem_summary?: string
escalation_reason?: string
steps_tried?: Array<{
step_type?: string
description?: string
response?: string
}>
steps_ruled_out?: string[]
remaining_hypotheses?: string[]
suggested_next_steps?: string[]
confidence_at_escalation?: number
}
interface SessionBriefingProps {
escalationPackage: EscalationPackage
originalEngineerName?: string
onContinue: () => void
onFresh: (context: string) => void
isProcessing: boolean
}
export function SessionBriefing({
escalationPackage,
originalEngineerName,
onContinue,
onFresh,
isProcessing,
}: SessionBriefingProps) {
const [showSteps, setShowSteps] = useState(false)
const [freshMode, setFreshMode] = useState(false)
const [freshContext, setFreshContext] = useState('')
const pkg = escalationPackage
return (
<div className="glass-card-static border-l-2 border-l-amber-500 p-5 space-y-4">
<div>
<h3 className="font-heading text-base font-semibold text-foreground">
Escalation from {originalEngineerName || 'another engineer'}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Review the briefing below, then choose how to proceed.
</p>
</div>
{/* Problem */}
{pkg.problem_summary && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Problem</h4>
<p className="text-sm text-foreground">{pkg.problem_summary}</p>
</div>
)}
{/* Escalation reason */}
{pkg.escalation_reason && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Why escalated</h4>
<p className="text-sm text-amber-400">{pkg.escalation_reason}</p>
</div>
)}
{/* Steps taken (collapsible) */}
{pkg.steps_tried && pkg.steps_tried.length > 0 && (
<div>
<button
onClick={() => setShowSteps(!showSteps)}
className="flex items-center gap-1.5 font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] hover:text-foreground transition-colors"
>
{showSteps ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Steps taken ({pkg.steps_tried.length})
</button>
{showSteps && (
<div className="mt-2 space-y-1.5">
{pkg.steps_tried.map((step, i) => (
<div key={i} className="rounded-lg bg-card/50 px-3 py-2 text-xs">
<p className="text-foreground">{i + 1}. {step.description}</p>
{step.response && (
<p className="mt-0.5 text-primary"> {step.response}</p>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Remaining hypotheses */}
{pkg.remaining_hypotheses && pkg.remaining_hypotheses.length > 0 && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Remaining hypotheses</h4>
<ul className="space-y-1">
{pkg.remaining_hypotheses.map((h, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<span className="text-primary mt-0.5"></span>
{h}
</li>
))}
</ul>
</div>
)}
{/* Suggested next steps */}
{pkg.suggested_next_steps && pkg.suggested_next_steps.length > 0 && (
<div>
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">Suggested next steps</h4>
<ul className="space-y-1">
{pkg.suggested_next_steps.map((s, i) => (
<li key={i} className="text-sm text-foreground flex items-start gap-2">
<span className="text-emerald-400 mt-0.5"></span>
{s}
</li>
))}
</ul>
</div>
)}
{/* Action buttons */}
{!freshMode ? (
<div className="flex gap-2 pt-2">
<button
onClick={onContinue}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-gradient-brand px-4 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 transition-all"
>
<ArrowRight size={14} />
Continue Where They Left Off
</button>
<button
onClick={() => setFreshMode(true)}
disabled={isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2.5 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 transition-colors"
>
<MessageSquare size={14} />
Start Fresh With Context
</button>
</div>
) : (
<div className="space-y-3 pt-2">
<textarea
value={freshContext}
onChange={(e) => setFreshContext(e.target.value)}
placeholder="What additional information do you have, or what would you like to investigate first?"
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
rows={3}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={() => setFreshMode(false)}
disabled={isProcessing}
className="rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 transition-colors"
>
Back
</button>
<button
onClick={() => freshContext.trim() && onFresh(freshContext.trim())}
disabled={!freshContext.trim() || isProcessing}
className="flex-1 flex items-center justify-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 transition-all"
>
<ArrowRight size={14} />
Start Diagnosis
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,15 +1,111 @@
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star } from 'lucide-react'
import { useState } from 'react'
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star, AlertTriangle, Loader2, RefreshCw, Info } from 'lucide-react'
import { aiSessionsApi } from '@/api'
import { toast } from '@/lib/toast'
import type { SessionDocumentation } from '@/types/ai-session'
interface SessionDocViewProps {
documentation: SessionDocumentation
onRate?: (rating: number) => void
currentRating?: number | null
psaPushStatus?: string | null
psaPushError?: string | null
memberMappingWarning?: string | null
sessionId?: string
ticketId?: string | null
}
export function SessionDocView({ documentation, onRate, currentRating }: SessionDocViewProps) {
export function SessionDocView({
documentation,
onRate,
currentRating,
psaPushStatus,
psaPushError,
memberMappingWarning,
sessionId,
ticketId,
}: SessionDocViewProps) {
const [retrying, setRetrying] = useState(false)
const [currentPushStatus, setCurrentPushStatus] = useState(psaPushStatus)
const [currentPushError, setCurrentPushError] = useState(psaPushError)
const handleRetry = async () => {
if (!sessionId) return
setRetrying(true)
try {
const result = await aiSessionsApi.retryPsaPush(sessionId)
setCurrentPushStatus(result.psa_push_status)
setCurrentPushError(result.psa_push_error)
if (result.psa_push_status === 'sent') {
toast.success('Documentation pushed to ticket successfully')
}
} catch {
toast.error('Retry failed')
} finally {
setRetrying(false)
}
}
return (
<div className="space-y-5">
{/* PSA Push Status */}
{currentPushStatus && currentPushStatus !== 'no_psa' && (
<div
className={`rounded-xl border px-4 py-3 flex items-center gap-3 ${
currentPushStatus === 'sent'
? 'border-emerald-400/20 bg-emerald-400/5'
: currentPushStatus === 'pending_retry'
? 'border-amber-400/20 bg-amber-400/5'
: 'border-rose-500/20 bg-rose-500/5'
}`}
>
{currentPushStatus === 'sent' && (
<>
<CheckCircle2 size={16} className="text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-400">
Documentation pushed to ticket {ticketId ? `#${ticketId}` : ''}
</span>
</>
)}
{currentPushStatus === 'pending_retry' && (
<>
<Loader2 size={16} className="text-amber-400 shrink-0 animate-spin" />
<span className="text-sm text-amber-400">
Documentation queued for push will sync shortly
</span>
</>
)}
{currentPushStatus === 'failed' && (
<>
<AlertTriangle size={16} className="text-rose-500 shrink-0" />
<div className="flex-1">
<span className="text-sm text-rose-500">
Failed to push to ticket{currentPushError ? `${currentPushError}` : ''}
</span>
</div>
<button
onClick={handleRetry}
disabled={retrying}
className="flex items-center gap-1.5 rounded-lg bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-500 hover:bg-rose-500/20 transition-colors disabled:opacity-50"
>
{retrying ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
Retry
</button>
</>
)}
</div>
)}
{/* Member mapping warning */}
{memberMappingWarning && (
<div className="rounded-xl border border-blue-400/20 bg-blue-400/5 px-4 py-3 flex items-start gap-3">
<Info size={16} className="text-blue-400 shrink-0 mt-0.5" />
<span className="text-sm text-blue-400">
Time entry was not created {memberMappingWarning} Session timing is included in the ticket note.
</span>
</div>
)}
{/* Header */}
<div className="glass-card-static p-5">
<div className="flex items-start gap-3">

View File

@@ -0,0 +1,106 @@
import { ExternalLink, Cpu, Building2 } from 'lucide-react'
interface TicketData {
ticket?: {
id?: number | string
summary?: string
status?: string
priority?: string
board?: string
}
company?: {
name?: string
}
configurations?: Array<{
device_identifier?: string
type?: string
ip_address?: string
}>
}
interface SessionTicketCardProps {
ticketId: string
ticketData: TicketData | null
siteUrl?: string
}
export function SessionTicketCard({ ticketId, ticketData, siteUrl }: SessionTicketCardProps) {
const ticket = ticketData?.ticket
const company = ticketData?.company
const configs = ticketData?.configurations
const ticketUrl = siteUrl
? `${siteUrl}/v4_6_release/services/system_io/Service/fv_sr100_request.rails?service_recid=${ticketId}`
: null
return (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-3 space-y-2">
<div className="flex items-start justify-between">
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170]">
Linked Ticket
</h4>
{ticketUrl && (
<a
href={ticketUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary transition-colors"
title="Open in ConnectWise"
>
<ExternalLink size={12} />
</a>
)}
</div>
<p className="text-sm font-semibold text-foreground">
<span className="text-primary">#{ticketId}</span>
{ticket?.summary && (
<span> {ticket.summary}</span>
)}
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{company?.name && (
<span className="flex items-center gap-1">
<Building2 size={10} />
{company.name}
</span>
)}
{ticket?.priority && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticket.priority}</span>
</>
)}
{ticket?.status && (
<>
<span className="text-[#5a6170]">&bull;</span>
<span>{ticket.status}</span>
</>
)}
</div>
{configs && configs.length > 0 && (
<div className="border-t border-border/50 pt-2 mt-2">
<p className="font-label text-[0.5625rem] uppercase tracking-wider text-[#5a6170] mb-1">
Devices
</p>
<div className="space-y-0.5">
{configs.slice(0, 3).map((cfg, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Cpu size={10} />
<span>{cfg.device_identifier}</span>
{cfg.type && <span className="text-[#5a6170]">({cfg.type})</span>}
</div>
))}
{configs.length > 3 && (
<p className="text-[0.625rem] text-[#5a6170]">
+{configs.length - 3} more
</p>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -6,3 +6,7 @@ export { FlowPilotActionBar } from './FlowPilotActionBar'
export { ConfidenceIndicator } from './ConfidenceIndicator'
export { SessionDocView } from './SessionDocView'
export { AISessionListItem } from './AISessionListItem'
export { SessionTicketCard } from './SessionTicketCard'
export { EscalateModal } from './EscalateModal'
export { EscalationQueue } from './EscalationQueue'
export { SessionBriefing } from './SessionBriefing'

View File

@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
import {
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles,
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles, AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -86,6 +86,7 @@ export function Sidebar() {
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" iconColor="text-amber-400" collapsed />
<NavItem href="/trees" icon={Network} label="All Flows" matchPaths={['/trees', '/flows']} iconColor={NAV_COLORS.flows} collapsed />
<NavItem href="/assistant" icon={Brain} label="FlowPilot" iconColor={NAV_COLORS.flowPilot} collapsed />
<NavItem href="/scripts" icon={Code2} label="Script Library" iconColor={NAV_COLORS.scripts} collapsed />
@@ -135,6 +136,7 @@ export function Sidebar() {
Resolve
</div>
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} />
<NavItem href="/escalations" icon={AlertTriangle} label="Escalations" iconColor="text-amber-400" />
<NavItem
href="/trees"
icon={Network}

View File

@@ -12,11 +12,14 @@ type Mode = 'search' | 'manual'
interface Props {
open: boolean
onClose: () => void
sessionId: string
onLinked: (ticketId: string, ticket: PSATicketInfo) => void
/** Legacy session linking mode — pass sessionId + onLinked */
sessionId?: string
onLinked?: (ticketId: string, ticket: PSATicketInfo) => void
/** Selection-only mode — pass onSelect instead. Returns selected ticket without linking. */
onSelect?: (ticketId: string, ticket: PSATicketInfo) => void
}
export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props) {
export function TicketPickerModal({ open, onClose, sessionId, onLinked, onSelect }: Props) {
const [mode, setMode] = useState<Mode>('search')
// Search mode state
@@ -138,11 +141,22 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
const handleLink = async () => {
if (!selectedTicket || !selectedTicketId) return
// Selection-only mode — return ticket data without linking
if (onSelect) {
onSelect(selectedTicketId, selectedTicket)
handleReset()
onClose()
return
}
// Legacy session linking mode
if (!sessionId) return
setIsLinking(true)
setError(null)
try {
await sessionPsaApi.linkTicket(sessionId, selectedTicketId)
onLinked(selectedTicketId, selectedTicket)
onLinked?.(selectedTicketId, selectedTicket)
handleReset()
} catch (err: unknown) {
const message =
@@ -195,7 +209,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
}
return (
<Modal isOpen={open} onClose={handleClose} title="Link ConnectWise Ticket" size="sm">
<Modal isOpen={open} onClose={handleClose} title={onSelect ? 'Select ConnectWise Ticket' : 'Link ConnectWise Ticket'} size="sm">
<div className="space-y-4">
{/* Mode tabs */}
<div className="flex gap-1 rounded-lg bg-white/[0.03] p-1">
@@ -411,7 +425,7 @@ export function TicketPickerModal({ open, onClose, sessionId, onLinked }: Props)
loading={isLinking}
>
<Ticket className="h-4 w-4" />
Link This Ticket
{onSelect ? 'Select This Ticket' : 'Link This Ticket'}
</Button>
</div>
</div>

View File

@@ -27,6 +27,8 @@ export interface UseFlowPilotSession {
respondToStep: (response: StepResponseRequest) => Promise<void>
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
pauseSession: () => Promise<void>
resumeOwnSession: () => Promise<void>
rateSession: (rating: number, feedback?: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
@@ -37,6 +39,9 @@ export interface UseFlowPilotSession {
// Post-close
documentation: SessionDocumentation | null
psaPushStatus: string | null
psaPushError: string | null
memberMappingWarning: string | null
}
export function useFlowPilotSession(): UseFlowPilotSession {
@@ -47,6 +52,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [documentation, setDocumentation] = useState<SessionDocumentation | null>(null)
const [psaPushStatus, setPsaPushStatus] = useState<string | null>(null)
const [psaPushError, setPsaPushError] = useState<string | null>(null)
const [memberMappingWarning, setMemberMappingWarning] = useState<string | null>(null)
const startSession = useCallback(async (intake: AISessionCreateRequest) => {
setIsLoading(true)
@@ -73,6 +81,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
resolution_action: null,
escalation_reason: null,
session_feedback: null,
psa_ticket_id: intake.psa_ticket_id ?? null,
psa_connection_id: intake.psa_connection_id ?? null,
ticket_data: null,
steps: [firstStep],
})
setAllSteps([firstStep])
@@ -120,6 +131,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const result = await aiSessionsApi.resolveSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'resolved' } : null)
setDocumentation(result.documentation)
setPsaPushStatus(result.psa_push_status)
setPsaPushError(result.psa_push_error)
setMemberMappingWarning(result.member_mapping_warning)
setCurrentStep(null)
toast.success('Session resolved')
return result.documentation
@@ -139,6 +153,9 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const result = await aiSessionsApi.escalateSession(session.id, data)
setSession(prev => prev ? { ...prev, status: 'escalated' } : null)
setDocumentation(result.documentation)
setPsaPushStatus(result.psa_push_status)
setPsaPushError(result.psa_push_error)
setMemberMappingWarning(result.member_mapping_warning)
setCurrentStep(null)
toast.success('Session escalated')
return result.documentation
@@ -151,6 +168,28 @@ export function useFlowPilotSession(): UseFlowPilotSession {
}
}, [session])
const pauseSession = useCallback(async () => {
if (!session) return
try {
await aiSessionsApi.pauseSession(session.id)
setSession(prev => prev ? { ...prev, status: 'paused' } : null)
toast.success('Session paused')
} catch {
toast.error('Failed to pause session')
}
}, [session])
const resumeOwnSession = useCallback(async () => {
if (!session) return
try {
await aiSessionsApi.resumeSession(session.id)
setSession(prev => prev ? { ...prev, status: 'active' } : null)
toast.success('Session resumed')
} catch {
toast.error('Failed to resume session')
}
}, [session])
const rateSession = useCallback(async (rating: number, feedback?: string) => {
if (!session) return
try {
@@ -169,7 +208,12 @@ export function useFlowPilotSession(): UseFlowPilotSession {
const detail = await aiSessionsApi.getSession(sessionId)
setSession(detail)
setAllSteps(detail.steps)
setCurrentStep(detail.status === 'active' ? detail.steps[detail.steps.length - 1] ?? null : null)
// Set current step for active and paused sessions (paused can be resumed)
setCurrentStep(
detail.status === 'active' || detail.status === 'paused'
? detail.steps[detail.steps.length - 1] ?? null
: null
)
if (detail.status === 'resolved' || detail.status === 'escalated') {
const doc = await aiSessionsApi.getDocumentation(sessionId)
@@ -199,11 +243,16 @@ export function useFlowPilotSession(): UseFlowPilotSession {
respondToStep,
resolveSession,
escalateSession,
pauseSession,
resumeOwnSession,
rateSession,
loadSession,
isActive,
canResolve,
canEscalate,
documentation,
psaPushStatus,
psaPushError,
memberMappingWarning,
}
}

View File

@@ -0,0 +1,20 @@
import { AlertTriangle } from 'lucide-react'
import { EscalationQueue } from '@/components/flowpilot'
export default function EscalationQueuePage() {
return (
<div className="mx-auto max-w-3xl p-6">
<div className="flex items-center gap-3 mb-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10">
<AlertTriangle size={16} className="text-amber-400" />
</span>
<div>
<h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1>
<p className="text-sm text-muted-foreground">Sessions from your team waiting for pickup</p>
</div>
</div>
<EscalationQueue />
</div>
)
}

View File

@@ -1,12 +1,17 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Sparkles } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { Sparkles, Loader2 } from 'lucide-react'
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
import { FlowPilotIntake, FlowPilotSession } from '@/components/flowpilot'
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
import { aiSessionsApi } from '@/api'
import { toast } from '@/lib/toast'
export default function FlowPilotSessionPage() {
const { sessionId } = useParams<{ sessionId?: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const isPickup = searchParams.get('pickup') === 'true'
const fp = useFlowPilotSession()
const [pickingUp, setPickingUp] = useState(false)
// Load existing session if ID in URL
useEffect(() => {
@@ -15,6 +20,40 @@ export default function FlowPilotSessionPage() {
}
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
const handlePickupContinue = async () => {
if (!sessionId) return
setPickingUp(true)
try {
await aiSessionsApi.pickupSession(sessionId, { resume_mode: 'continue' })
// Clear pickup param and reload the session as active
setSearchParams({})
await fp.loadSession(sessionId)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setPickingUp(false)
}
}
const handlePickupFresh = async (context: string) => {
if (!sessionId) return
setPickingUp(true)
try {
await aiSessionsApi.pickupSession(sessionId, {
resume_mode: 'fresh',
additional_context: context,
})
setSearchParams({})
await fp.loadSession(sessionId)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to pick up session'
toast.error(message)
} finally {
setPickingUp(false)
}
}
// Error state
if (fp.error && !fp.session) {
return (
@@ -32,6 +71,15 @@ export default function FlowPilotSessionPage() {
)
}
// Loading
if (fp.isLoading && !fp.session) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)
}
// Intake screen (no session yet)
if (!fp.session) {
return (
@@ -41,6 +89,56 @@ export default function FlowPilotSessionPage() {
)
}
// Escalation pickup briefing
if (isPickup && fp.session.status === 'requesting_escalation' && fp.session.escalation_reason) {
// Build escalation package from session detail
// The escalation_package is in the session but not directly on AISessionDetail —
// we use what's available from the session fields
const escalationPackage = {
problem_summary: fp.session.problem_summary ?? undefined,
escalation_reason: fp.session.escalation_reason ?? undefined,
// Steps are available from the session detail
steps_tried: fp.allSteps.map(step => ({
step_type: step.step_type,
description: (step.content as Record<string, string>)?.text || '',
})),
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-amber-500/10">
<Sparkles size={14} className="text-amber-400" />
</span>
<div className="flex-1 min-w-0">
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
Escalation Pickup {fp.session.problem_summary || 'FlowPilot Session'}
</h1>
</div>
<span className="font-label rounded-md bg-amber-500/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-amber-400 border border-amber-500/20">
Awaiting pickup
</span>
</div>
{/* Briefing */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<SessionBriefing
escalationPackage={escalationPackage}
onContinue={handlePickupContinue}
onFresh={handlePickupFresh}
isProcessing={pickingUp}
/>
</div>
</div>
</div>
)
}
// Active/completed session
return (
<div className="flex h-full flex-col">
@@ -72,9 +170,14 @@ export default function FlowPilotSessionPage() {
canResolve={fp.canResolve}
canEscalate={fp.canEscalate}
documentation={fp.documentation}
psaPushStatus={fp.psaPushStatus}
psaPushError={fp.psaPushError}
memberMappingWarning={fp.memberMappingWarning}
onRespond={fp.respondToStep}
onResolve={fp.resolveSession}
onEscalate={fp.escalateSession}
onPause={fp.pauseSession}
onResume={fp.resumeOwnSession}
onRate={fp.rateSession}
/>
</div>

View File

@@ -41,7 +41,7 @@ const emptyForm: ConnectionForm = {
private_key: '',
}
type Tab = 'connection' | 'member-mapping' | 'post-history'
type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings'
export function IntegrationsPage() {
const [activeTab, setActiveTab] = useState<Tab>('connection')
@@ -236,6 +236,7 @@ export function IntegrationsPage() {
{ id: 'connection' as Tab, label: 'Connection', icon: Plug },
{ id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users },
{ id: 'post-history' as Tab, label: 'Post History', icon: History },
{ id: 'flowpilot-settings' as Tab, label: 'FlowPilot', icon: Zap },
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
@@ -549,6 +550,11 @@ export function IntegrationsPage() {
</div>
</div>
)}
{/* FlowPilot Settings Tab */}
{activeTab === 'flowpilot-settings' && (
<FlowPilotSettingsTab connection={connection} />
)}
</div>
</>
)
@@ -812,4 +818,221 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
)
}
/* ─── FlowPilot Settings Tab ─── */
function FlowPilotSettingsTab({ connection }: { connection: PsaConnectionResponse | null }) {
const [settings, setSettings] = useState<Record<string, unknown> | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (!connection) {
setIsLoading(false)
return
}
integrationsApi.getFlowpilotSettings(connection.id)
.then(s => setSettings(s as unknown as Record<string, unknown>))
.catch(() => toast.error('Failed to load FlowPilot settings'))
.finally(() => setIsLoading(false))
}, [connection])
const updateSetting = async (key: string, value: unknown) => {
if (!connection || !settings) return
const updated = { ...settings, [key]: value }
setSettings(updated)
setIsSaving(true)
try {
await integrationsApi.updateFlowpilotSettings(connection.id, { [key]: value })
} catch {
toast.error('Failed to save setting')
// Revert
setSettings(settings)
} finally {
setIsSaving(false)
}
}
if (!connection) {
return (
<div className="max-w-3xl">
<div className="glass-card-static p-6 text-center">
<p className="text-sm text-muted-foreground">Connect your PSA first to configure FlowPilot settings.</p>
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)
}
if (!settings) return null
return (
<div className="max-w-3xl space-y-4">
<div className="glass-card-static p-6">
<div className="flex items-center gap-3 mb-4">
<Zap className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">FlowPilot Settings</h2>
</div>
<p className="text-sm text-muted-foreground mb-6">
Configure how FlowPilot integrates with your ConnectWise PSA when sessions are resolved or escalated.
</p>
<div className="space-y-5">
{/* Auto-push documentation */}
<SettingToggle
label="Auto-push documentation"
description="Automatically push session documentation to linked tickets on resolution"
checked={settings.auto_push as boolean}
onChange={(v) => updateSetting('auto_push', v)}
disabled={isSaving}
/>
{/* Auto-create time entry */}
<SettingToggle
label="Auto-create time entry"
description="Automatically create a time entry when resolving (requires CW member mapping)"
checked={settings.auto_time_entry as boolean}
onChange={(v) => updateSetting('auto_time_entry', v)}
disabled={isSaving}
/>
{/* Time rounding */}
<SettingSelect
label="Time rounding"
description="How to round session duration for time entries"
value={settings.time_rounding as string}
options={[
{ value: '15min', label: 'Nearest 15 minutes' },
{ value: '30min', label: 'Nearest 30 minutes' },
{ value: 'exact', label: 'Exact time' },
{ value: 'none', label: "Don't create time entries" },
]}
onChange={(v) => updateSetting('time_rounding', v)}
disabled={isSaving}
/>
{/* Note visibility */}
<SettingSelect
label="Default note visibility"
description="Who can see the session notes posted to tickets"
value={settings.note_visibility as string}
options={[
{ value: 'internal', label: 'Internal only' },
{ value: 'both', label: 'Internal and external' },
]}
onChange={(v) => updateSetting('note_visibility', v)}
disabled={isSaving}
/>
{/* Include diagnostic steps */}
<SettingToggle
label="Include diagnostic steps in notes"
description="When off, only push the summary — not the full diagnostic trail"
checked={settings.include_diagnostic_steps as boolean}
onChange={(v) => updateSetting('include_diagnostic_steps', v)}
disabled={isSaving}
/>
{/* Prompt for status on resolution */}
<SettingToggle
label="Prompt for ticket status on resolution"
description="Show a status dropdown when resolving — options pulled from the ticket's board"
checked={settings.prompt_status_on_resolution as boolean}
onChange={(v) => updateSetting('prompt_status_on_resolution', v)}
disabled={isSaving}
/>
{/* Prompt for status on escalation */}
<SettingToggle
label="Prompt for ticket status on escalation"
description="Show a status dropdown when escalating — options pulled from the ticket's board"
checked={settings.prompt_status_on_escalation as boolean}
onChange={(v) => updateSetting('prompt_status_on_escalation', v)}
disabled={isSaving}
/>
</div>
</div>
</div>
)
}
/* ─── Setting Components ─── */
function SettingToggle({
label,
description,
checked,
onChange,
disabled,
}: {
label: string
description: string
checked: boolean
onChange: (value: boolean) => void
disabled?: boolean
}) {
return (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button
onClick={() => onChange(!checked)}
disabled={disabled}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:opacity-50',
checked ? 'bg-primary' : 'bg-[rgba(255,255,255,0.1)]'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform',
checked ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
</div>
)
}
function SettingSelect({
label,
description,
value,
options,
onChange,
disabled,
}: {
label: string
description: string
value: string
options: { value: string; label: string }[]
onChange: (value: string) => void
disabled?: boolean
}) {
return (
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">{description}</p>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="w-full max-w-xs rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none disabled:opacity-50"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)
}
export default IntegrationsPage

View File

@@ -46,6 +46,7 @@ const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
@@ -172,6 +173,7 @@ export const router = sentryCreateBrowserRouter([
{ path: 'flow-assist', element: page(FlowAssistPage) },
{ path: 'pilot', element: page(FlowPilotSessionPage) },
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
{ path: 'escalations', element: page(EscalationQueuePage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
// Admin routes

View File

@@ -17,6 +17,7 @@ export interface AISessionCreateResponse {
matched_flow_name: string | null
match_score: number | null
first_step: AISessionStepResponse
psa_context_status: string | null // "loaded" | "unavailable" | null
}
// ── Step interaction ──
@@ -95,6 +96,9 @@ export interface SessionCloseResponse {
session_id: string
status: string
documentation: SessionDocumentation
psa_push_status: string // "sent" | "pending_retry" | "no_psa" | "failed"
psa_push_error: string | null
member_mapping_warning: string | null
}
export interface RateSessionRequest {
@@ -113,10 +117,17 @@ export interface AISessionSummary {
confidence_tier: string
step_count: number
session_rating: number | null
psa_ticket_id: string | null
escalation_reason: string | null
created_at: string
resolved_at: string | null
}
export interface PickupSessionRequest {
resume_mode: 'continue' | 'fresh'
additional_context?: string
}
export interface AISessionDetail extends AISessionSummary {
intake_content: Record<string, unknown>
matched_flow_id: string | null
@@ -125,5 +136,8 @@ export interface AISessionDetail extends AISessionSummary {
resolution_action: string | null
escalation_reason: string | null
session_feedback: string | null
psa_ticket_id: string | null
psa_connection_id: string | null
ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[]
}

View File

@@ -121,3 +121,13 @@ export interface AutoMatchResult {
matched: PsaMemberMappingResponse[]
unmatched_users: number
}
export interface FlowpilotSettings {
auto_push: boolean
auto_time_entry: boolean
time_rounding: string // "15min" | "30min" | "exact" | "none"
note_visibility: string // "internal" | "both"
include_diagnostic_steps: boolean
prompt_status_on_resolution: boolean
prompt_status_on_escalation: boolean
}