# PSA Ticket Management Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add PSA ticket management to ResolutionFlow — a dedicated Tickets page, an updated TicketQueue dashboard widget, and spin-off ticket creation from ResolutionAssist sessions. **Architecture:** Dedicated `ticket_service.py` wraps PSA provider mutations; new normalized DTOs in `psa_tickets.py`; `search_tickets` updated to return paginated results via parallel CW count fetch. Frontend uses URL params for filter/pagination state, `TicketDetailPanel` hydrates via existing `getTicketContext` + new `listResources` endpoint. **Tech Stack:** FastAPI, SQLAlchemy async, Pydantic v2, Anthropic SDK (AI parse), React 19, TypeScript, Tailwind v4, React Router v7 `useSearchParams`, Lucide icons. --- ## Phase 1 — Backend: Provider Foundations ### Task 1: Add PaginatedTicketResult type + update provider base **Files:** - Modify: `backend/app/services/psa/types.py` - Modify: `backend/app/services/psa/base.py` - [ ] **Step 1: Add PaginatedTicketResult to types.py** ```python # backend/app/services/psa/types.py — add after PSABoard class from dataclasses import dataclass @dataclass class PaginatedTicketResult: items: list["PSATicket"] total: int page: int page_size: int ``` - [ ] **Step 2: Update search_tickets signature in base.py** ```python # backend/app/services/psa/base.py from .types import ( ConnectionTestResult, PSATicket, PaginatedTicketResult, # add PSANote, PSAStatus, PSACompany, PSAMember, PSAConfiguration, PSATimeEntry, PSABoard, ) # Change the search_tickets abstract method: @abstractmethod async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: ... ``` - [ ] **Step 3: Commit** ```bash git add backend/app/services/psa/types.py backend/app/services/psa/base.py git commit -m "feat(psa): add PaginatedTicketResult type, update provider search_tickets signature" ``` --- ### Task 2: Add new abstract provider methods **Files:** - Modify: `backend/app/services/psa/types.py` - Modify: `backend/app/services/psa/base.py` - [ ] **Step 1: Add PSAResource + PSACreatedTicket types to types.py** ```python # backend/app/services/psa/types.py — add after PaginatedTicketResult class PSAResource(BaseModel): member_id: int member_name: str member_identifier: str is_rf_user: bool = False class PSACreatedTicket(BaseModel): id: int summary: str board_name: str status_name: str priority_name: str company_name: str resources: list[PSAResource] = [] class TicketCreatePayload(BaseModel): summary: str company_id: int board_id: int status_id: int priority_id: int description: str | None = None assigned_member_id: int | None = None ``` - [ ] **Step 2: Add 4 abstract methods to base.py** ```python # backend/app/services/psa/base.py — add these after get_ticket_configurations @abstractmethod async def list_resources(self, ticket_id: int) -> list[PSAResource]: ... @abstractmethod async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: ... @abstractmethod async def remove_resource(self, ticket_id: int, member_id: int) -> None: ... @abstractmethod async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: ... ``` Also add `list_priorities` for the full-form dropdown: ```python @abstractmethod async def list_priorities(self) -> list[dict]: ... ``` - [ ] **Step 3: Update base.py imports** ```python from .types import ( ConnectionTestResult, PSATicket, PaginatedTicketResult, PSANote, PSAStatus, PSACompany, PSAMember, PSAConfiguration, PSATimeEntry, PSABoard, PSAResource, PSACreatedTicket, TicketCreatePayload, ) ``` - [ ] **Step 4: Commit** ```bash git add backend/app/services/psa/types.py backend/app/services/psa/base.py git commit -m "feat(psa): add PSAResource, TicketCreatePayload types and abstract provider methods" ``` --- ### Task 3: Implement new CW provider methods **Files:** - Modify: `backend/app/services/psa/connectwise/provider.py` - [ ] **Step 1: Update CW search_tickets to return PaginatedTicketResult** In `ConnectWiseProvider.search_tickets()`, replace the final `return [...]` block with a parallel count fetch: ```python async def search_tickets(self, query: str, **filters) -> PaginatedTicketResult: page_size = filters.get("page_size", 10) page = filters.get("page", 1) params: dict = { "fields": "id,summary,company,board,status,priority,closedFlag", "orderBy": "priority/sort asc,dateEntered desc", "pageSize": page_size, "page": page, } conditions: list[str] = [] if query: conditions.append(f"summary contains '{query}'") if filters.get("board_id"): conditions.append(f"board/id = {filters['board_id']}") if filters.get("status_id"): conditions.append(f"status/id = {filters['status_id']}") if not filters.get("include_closed", False): conditions.append("closedFlag = false") if filters.get("member_identifier") is not None: conditions.append(f"resources contains '{filters['member_identifier']}'") if filters.get("unassigned", False): conditions.append("resources = null") board_ids: list[int] = filters.get("board_ids") or [] if board_ids: board_list = ", ".join(str(bid) for bid in board_ids) conditions.append(f"board/id in ({board_list})") condition_str = " and ".join(conditions) if conditions else "" if condition_str: params["conditions"] = condition_str count_params: dict = {} if condition_str: count_params["conditions"] = condition_str # Fire page fetch + count in parallel data, count_data = await asyncio.gather( self.client.get("/service/tickets", params=params), self.client.get("/service/tickets/count", params=count_params), ) items = [self._map_ticket(t) for t in (data if isinstance(data, list) else [])] total = count_data.get("count", len(items)) if isinstance(count_data, dict) else len(items) return PaginatedTicketResult(items=items, total=total, page=page, page_size=page_size) ``` - [ ] **Step 2: Add update import in provider.py** ```python from app.services.psa.types import ( ConnectionTestResult, PSATicket, PaginatedTicketResult, PSANote, PSAStatus, PSACompany, PSAMember, PSAConfiguration, PSATimeEntry, PSABoard, PSAResource, PSACreatedTicket, TicketCreatePayload, ) ``` - [ ] **Step 3: Update _map_ticket to expose company_id and board_id** ```python @staticmethod def _map_ticket(data: dict) -> PSATicket: company = data.get("company") or {} board = data.get("board") or {} status = data.get("status") or {} priority = data.get("priority") or {} return PSATicket( id=str(data.get("id", "")), summary=data.get("summary", ""), company_name=company.get("name"), company_id=str(company.get("id")) if company.get("id") else None, board_name=board.get("name"), board_id=board.get("id"), status_name=status.get("name"), status_id=status.get("id"), priority_name=priority.get("name"), priority_id=priority.get("id"), closed=data.get("closedFlag", False), ) ``` - [ ] **Step 4: Implement list_resources** ```python async def list_resources(self, ticket_id: int) -> list[PSAResource]: data = await self.client.get(f"/service/tickets/{ticket_id}/members") results = [] for m in (data if isinstance(data, list) else []): member = m.get("member") or {} results.append(PSAResource( member_id=member.get("id", 0), member_name=member.get("name", ""), member_identifier=member.get("identifier", ""), )) return results ``` - [ ] **Step 5: Implement add_resource** ```python async def add_resource(self, ticket_id: int, member_id: int) -> PSAResource: data = await self.client.post( f"/service/tickets/{ticket_id}/members", json={"member": {"id": member_id}}, ) member = (data.get("member") or {}) if isinstance(data, dict) else {} return PSAResource( member_id=member.get("id", member_id), member_name=member.get("name", ""), member_identifier=member.get("identifier", ""), ) ``` - [ ] **Step 6: Implement remove_resource** ```python async def remove_resource(self, ticket_id: int, member_id: int) -> None: # CW DELETE /service/tickets/{id}/members requires the member record id, # not the member id. Fetch the list first to find the record id. members_data = await self.client.get(f"/service/tickets/{ticket_id}/members") record_id = None for m in (members_data if isinstance(members_data, list) else []): if (m.get("member") or {}).get("id") == member_id: record_id = m.get("id") break if record_id is None: return # Already not assigned — idempotent await self.client.delete(f"/service/tickets/{ticket_id}/members/{record_id}") ``` - [ ] **Step 7: Implement create_ticket** ```python async def create_ticket(self, payload: TicketCreatePayload) -> PSACreatedTicket: body: dict = { "summary": payload.summary, "board": {"id": payload.board_id}, "company": {"id": payload.company_id}, "status": {"id": payload.status_id}, "priority": {"id": payload.priority_id}, } if payload.description: body["initialDescription"] = payload.description if payload.assigned_member_id: body["owner"] = {"id": payload.assigned_member_id} data = await self.client.post("/service/tickets", json=body) # Fetch resources for the created ticket ticket_id = data.get("id") resources: list[PSAResource] = [] if ticket_id and payload.assigned_member_id: try: resources = await self.list_resources(ticket_id) except Exception: pass company = data.get("company") or {} board = data.get("board") or {} status = data.get("status") or {} priority = data.get("priority") or {} return PSACreatedTicket( id=ticket_id or 0, summary=data.get("summary", payload.summary), board_name=board.get("name", ""), status_name=status.get("name", ""), priority_name=priority.get("name", ""), company_name=company.get("name", ""), resources=resources, ) ``` - [ ] **Step 8: Implement list_priorities** ```python async def list_priorities(self) -> list[dict]: data = await self.client.get("/service/priorities", params={"pageSize": 50}) return [ {"id": p.get("id"), "name": p.get("name")} for p in (data if isinstance(data, list) else []) ] ``` - [ ] **Step 9: Commit** ```bash git add backend/app/services/psa/connectwise/provider.py git commit -m "feat(psa): implement list/add/remove resources, create_ticket, paginated search in CW provider" ``` --- ### Task 4: Update PSATicketInfo schema + add psa_tickets.py schemas **Files:** - Modify: `backend/app/schemas/psa_connection.py` - Create: `backend/app/schemas/psa_tickets.py` - [ ] **Step 1: Add company_id and board_id to PSATicketInfo in psa_connection.py** The existing `PSATicketInfo` equivalent for API responses is `PSATicketSearchResult`. Add company_id/board_id fields and a new `PSATicketInfoFull` for the get_ticket endpoint: ```python # backend/app/schemas/psa_connection.py — update PSATicketSearchResult class PSATicketSearchResult(BaseModel): id: str summary: str company_name: str | None = None company_id: str | None = None # add board_name: str | None = None board_id: int | None = None # add status_name: str | None = None status_id: int | None = None # add priority_name: str | None = None priority_id: int | None = None # add closed: bool = False ``` - [ ] **Step 2: Create psa_tickets.py with all new schemas** ```python # backend/app/schemas/psa_tickets.py """Normalized DTOs for ticket management endpoints.""" from __future__ import annotations from pydantic import BaseModel class PSAResourceSchema(BaseModel): member_id: int member_name: str member_identifier: str is_rf_user: bool = False class PSATicketCreatedSchema(BaseModel): id: int summary: str board_name: str status_name: str priority_name: str company_name: str resources: list[PSAResourceSchema] = [] class PSATicketStatusUpdateSchema(BaseModel): ticket_id: int previous_status: str new_status: str class TicketCreatePayloadSchema(BaseModel): summary: str company_id: int board_id: int status_id: int priority_id: int description: str | None = None assigned_member_id: int | None = None class TicketListResponseSchema(BaseModel): items: list # list[PSATicketSearchResult] — imported in endpoints total: int page: int page_size: int class AiParseRequestSchema(BaseModel): prompt: str class AiParseResponseSchema(BaseModel): summary: str | None = None company_id: int | None = None board_id: int | None = None priority_id: int | None = None status_id: int | None = None assigned_member_id: int | None = None description: str | None = None missing_fields: list[str] = [] warnings: list[str] = [] class PSAPrioritySchema(BaseModel): id: int name: str ``` - [ ] **Step 3: Commit** ```bash git add backend/app/schemas/psa_connection.py backend/app/schemas/psa_tickets.py git commit -m "feat(psa): expand PSATicketSearchResult with IDs, add psa_tickets.py schemas" ``` --- ## Phase 2 — Backend: ticket_service.py + Endpoints ### Task 5: Create ticket_service.py **Files:** - Create: `backend/app/services/ticket_service.py` - [ ] **Step 1: Write ticket_service.py** ```python # backend/app/services/ticket_service.py """Ticket mutation service — wraps PSA provider, resolves is_rf_user flag.""" from __future__ import annotations import logging from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.psa_connection import PsaConnection from app.models.psa_member_mapping import PsaMemberMapping from app.schemas.psa_tickets import ( PSAResourceSchema, PSATicketCreatedSchema, PSATicketStatusUpdateSchema, ) from app.services.psa.registry import get_provider_for_account from app.services.psa.types import TicketCreatePayload logger = logging.getLogger(__name__) async def _get_mapped_member_ids(account_id: UUID, db: AsyncSession) -> set[int]: """Return set of external_member_id ints that are mapped to RF users.""" conn_result = await db.execute( select(PsaConnection).where(PsaConnection.account_id == account_id) ) conn = conn_result.scalar_one_or_none() if not conn: return set() mappings = await db.execute( select(PsaMemberMapping).where(PsaMemberMapping.psa_connection_id == conn.id) ) return {int(m.external_member_id) for m in mappings.scalars().all() if m.external_member_id} async def list_resources( account_id: UUID, ticket_id: int, db: AsyncSession ) -> list[PSAResourceSchema]: provider = await get_provider_for_account(account_id, db) mapped_ids = await _get_mapped_member_ids(account_id, db) resources = await provider.list_resources(ticket_id) return [ PSAResourceSchema( member_id=r.member_id, member_name=r.member_name, member_identifier=r.member_identifier, is_rf_user=r.member_id in mapped_ids, ) for r in resources ] async def add_resource( account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession ) -> PSAResourceSchema: provider = await get_provider_for_account(account_id, db) mapped_ids = await _get_mapped_member_ids(account_id, db) resource = await provider.add_resource(ticket_id, member_id) return PSAResourceSchema( member_id=resource.member_id, member_name=resource.member_name, member_identifier=resource.member_identifier, is_rf_user=resource.member_id in mapped_ids, ) async def remove_resource( account_id: UUID, ticket_id: int, member_id: int, db: AsyncSession ) -> None: provider = await get_provider_for_account(account_id, db) await provider.remove_resource(ticket_id, member_id) async def update_status( account_id: UUID, ticket_id: int, status_id: int, db: AsyncSession ) -> PSATicketStatusUpdateSchema: provider = await get_provider_for_account(account_id, db) # get current status before updating ticket = await provider.get_ticket(str(ticket_id)) previous_status = ticket.status_name or "" await provider.update_ticket_status(str(ticket_id), status_id) # get new status name from statuses list statuses = await provider.get_ticket_statuses(ticket.board_id or 0) new_status = next((s.name for s in statuses if s.id == status_id), str(status_id)) return PSATicketStatusUpdateSchema( ticket_id=ticket_id, previous_status=previous_status, new_status=new_status, ) async def create_ticket( account_id: UUID, payload: TicketCreatePayload, db: AsyncSession ) -> PSATicketCreatedSchema: provider = await get_provider_for_account(account_id, db) mapped_ids = await _get_mapped_member_ids(account_id, db) result = await provider.create_ticket(payload) return PSATicketCreatedSchema( id=result.id, summary=result.summary, board_name=result.board_name, status_name=result.status_name, priority_name=result.priority_name, company_name=result.company_name, resources=[ PSAResourceSchema( member_id=r.member_id, member_name=r.member_name, member_identifier=r.member_identifier, is_rf_user=r.member_id in mapped_ids, ) for r in result.resources ], ) ``` - [ ] **Step 2: Commit** ```bash git add backend/app/services/ticket_service.py git commit -m "feat(psa): add ticket_service.py with list/add/remove resource, update_status, create_ticket" ``` --- ### Task 6: Update search endpoint + add new ticket endpoints **Files:** - Modify: `backend/app/api/endpoints/integrations.py` - [ ] **Step 1: Add new imports at top of integrations.py** ```python from app.schemas.psa_tickets import ( PSAResourceSchema, PSATicketCreatedSchema, PSATicketStatusUpdateSchema, TicketCreatePayloadSchema, AiParseRequestSchema, AiParseResponseSchema, PSAPrioritySchema, ) import app.services.ticket_service as ticket_svc ``` - [ ] **Step 2: Update search_tickets endpoint to return paginated response** Replace the existing `@router.get("/tickets/search", ...)` endpoint with: ```python @router.get("/tickets/search") async def search_tickets( current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], query: str = "", board_id: int | None = None, status_id: int | None = None, include_closed: bool = False, assigned_to_me: bool = False, unassigned: bool = False, board_ids: str = "", priority: str | None = None, company_id: int | None = None, page: int = 1, page_size: int = 25, ): """Search ConnectWise tickets — returns paginated TicketListResponse.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.registry import get_provider_for_account from app.services.psa.exceptions import PSAError member_identifier: str | None = None if assigned_to_me: conn_result = await db.execute( select(PsaConnection).where( PsaConnection.account_id == current_user.account_id, PsaConnection.is_active.is_(True), ) ) conn = conn_result.scalar_one_or_none() if conn: mapping_result = await db.execute( select(PsaMemberMapping).where( PsaMemberMapping.psa_connection_id == conn.id, PsaMemberMapping.user_id == current_user.id, ) ) mapping = mapping_result.scalar_one_or_none() if not mapping: return {"items": [], "total": 0, "page": page, "page_size": page_size} try: _provider = await get_provider_for_account(current_user.account_id, db) cw_members = await _provider.list_members() matched = next((m for m in cw_members if m.id == mapping.external_member_id), None) if matched: member_identifier = matched.identifier else: return {"items": [], "total": 0, "page": page, "page_size": page_size} except PSAError: return {"items": [], "total": 0, "page": page, "page_size": page_size} parsed_board_ids: list[int] = [] if board_ids: try: parsed_board_ids = [int(bid.strip()) for bid in board_ids.split(",") if bid.strip()] except ValueError: raise HTTPException(status_code=400, detail="board_ids must be comma-separated integers") try: provider = await get_provider_for_account(current_user.account_id, db) result = await provider.search_tickets( query, board_id=board_id, status_id=status_id, include_closed=include_closed, member_identifier=member_identifier, unassigned=unassigned, board_ids=parsed_board_ids, company_id=company_id, page=page, page_size=page_size, ) items = [ PSATicketSearchResult( id=t.id, summary=t.summary, company_name=t.company_name, company_id=t.company_id, board_name=t.board_name, board_id=t.board_id, status_name=t.status_name, status_id=t.status_id, priority_name=t.priority_name, priority_id=t.priority_id, closed=t.closed, ) for t in result.items ] return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size} except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) ``` - [ ] **Step 3: Add POST /tickets endpoint** ```python @router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201) async def create_ticket( data: TicketCreatePayloadSchema, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): """Create a new PSA ticket.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.exceptions import PSAError from app.services.psa.types import TicketCreatePayload try: return await ticket_svc.create_ticket( current_user.account_id, TicketCreatePayload(**data.model_dump()), db, ) except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) ``` - [ ] **Step 4: Add PATCH /tickets/{id}/status endpoint** ```python @router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema) async def update_ticket_status_endpoint( ticket_id: int, status_id: int, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): """Update a ticket's status.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.exceptions import PSAError try: return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db) except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) ``` - [ ] **Step 5: Add resource CRUD endpoints** ```python @router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema]) async def list_ticket_resources( ticket_id: int, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.exceptions import PSAError try: return await ticket_svc.list_resources(current_user.account_id, ticket_id, db) except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) @router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201) async def add_ticket_resource( ticket_id: int, member_id: int, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.exceptions import PSAError try: return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db) except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) @router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204) async def remove_ticket_resource( ticket_id: int, member_id: int, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.exceptions import PSAError try: await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db) except PSAError as e: raise HTTPException(status_code=502, detail=str(e)) ``` - [ ] **Step 6: Add GET /priorities endpoint** ```python @router.get("/priorities", response_model=list[PSAPrioritySchema]) async def list_priorities( current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): """List PSA priority levels for ticket creation form.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.registry import get_provider_for_account from app.services.psa.exceptions import PSAError try: provider = await get_provider_for_account(current_user.account_id, db) raw = await provider.list_priorities() return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")] except PSAError: return [] ``` - [ ] **Step 7: Commit** ```bash git add backend/app/api/endpoints/integrations.py git commit -m "feat(psa): update search endpoint for pagination, add create/status/resource/priority endpoints" ``` --- ### Task 7: Add AI parse endpoint **Files:** - Modify: `backend/app/api/endpoints/integrations.py` - [ ] **Step 1: Add ai-parse endpoint** ```python @router.post("/tickets/ai-parse", response_model=AiParseResponseSchema) async def ai_parse_ticket( data: AiParseRequestSchema, current_user: Annotated[User, Depends(require_engineer_or_admin)], db: Annotated[AsyncSession, Depends(get_db)], ): """Parse natural language into a ticket pre-fill payload using Claude.""" if not current_user.account_id: raise HTTPException(status_code=400, detail="User has no account") from app.services.psa.registry import get_provider_for_account from app.services.psa.exceptions import PSAError from app.core.config import settings import anthropic import json # Fetch boards + members for context (both cached) boards = [] members = [] try: provider = await get_provider_for_account(current_user.account_id, db) boards = await provider.list_boards() members = await provider.list_members() except PSAError: pass boards_list = [{"id": b.id, "name": b.name} for b in boards] members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members] system_prompt = """You are a ticket triage assistant for an MSP help desk. Extract structured ticket information from the engineer's natural language description. Return ONLY valid JSON matching this exact schema — no other text: { "summary": "short one-line ticket title or null", "board_id": "integer matching one of the provided boards or null", "priority_name": "one of: Critical, High, Medium, Low, or null", "description": "expanded description or null", "assignee_identifier": "member identifier string from the provided members list or null", "warnings": ["list of strings explaining what could not be resolved"] }""" user_msg = f"""Available boards: {json.dumps(boards_list)} Available members: {json.dumps(members_list[:50])} Engineer's description: {data.prompt}""" missing_fields: list[str] = [] warnings: list[str] = [] response_data = AiParseResponseSchema() try: client = anthropic.AsyncAnthropic( api_key=settings.ANTHROPIC_API_KEY, max_retries=1, ) msg = await client.messages.create( model=settings.get_model_for_action("default"), max_tokens=512, system=system_prompt, messages=[{"role": "user", "content": user_msg}], ) raw = msg.content[0].text.strip() # Strip markdown fences if present if raw.startswith("```"): import re raw = re.sub(r'^```(?:json)?\s*', '', raw) raw = re.sub(r'\s*```$', '', raw.strip()) parsed = json.loads(raw) response_data.summary = parsed.get("summary") response_data.description = parsed.get("description") warnings = parsed.get("warnings", []) # Resolve board_id if parsed.get("board_id"): board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None) if board_match: response_data.board_id = board_match.id else: missing_fields.append("board_id") warnings.append(f"Board ID {parsed['board_id']} not found") else: missing_fields.append("board_id") # Resolve assignee if parsed.get("assignee_identifier"): member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None) if member: response_data.assigned_member_id = int(member.id) else: warnings.append(f"Member '{parsed['assignee_identifier']}' not found") # Priority/status always need manual selection — they're board-dependent missing_fields.extend(["status_id", "priority_id", "company_id"]) except Exception as e: logger.warning("AI parse failed: %s", e) missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"] warnings = ["AI parsing failed — please fill in manually"] response_data.missing_fields = missing_fields response_data.warnings = warnings return response_data ``` - [ ] **Step 2: Commit** ```bash git add backend/app/api/endpoints/integrations.py git commit -m "feat(psa): add AI ticket parse endpoint" ``` --- ### Task 8: Update assistant_chat_service.py system prompt **Files:** - Modify: `backend/app/services/assistant_chat_service.py` - [ ] **Step 1: Add spin-off ticket rule to ASSISTANT_SYSTEM_PROMPT** Find the end of `ASSISTANT_SYSTEM_PROMPT` in `assistant_chat_service.py` and append this section before the closing `"""`: ```python # Add as the last section of ASSISTANT_SYSTEM_PROMPT, before the closing triple-quote: ## SPIN-OFF TICKET CREATION When you identify a second distinct issue that is clearly separate from the primary topic \ of this session, suggest creating a spin-off ticket using the [ACTIONS] marker below. \ Use this sparingly — only when the issue is genuinely independent, not for every tangential mention. Format: [ACTIONS] [ { "label": "Create ticket: ", "command": "create_spin_off_ticket", "description": "" } ] [/ACTIONS] ``` - [ ] **Step 2: Write a backend test for the new endpoints** ```python # backend/tests/test_psa_tickets.py """Routing and auth tests for new ticket management endpoints.""" import pytest @pytest.mark.asyncio async def test_create_ticket_requires_auth(client): """POST /tickets returns 401 without auth.""" response = await client.post( "/api/v1/integrations/psa/tickets", json={ "summary": "Test", "company_id": 1, "board_id": 1, "status_id": 1, "priority_id": 1 }, ) assert response.status_code == 401 @pytest.mark.asyncio async def test_list_resources_requires_auth(client): response = await client.get("/api/v1/integrations/psa/tickets/1/resources") assert response.status_code == 401 @pytest.mark.asyncio async def test_search_tickets_returns_paginated_shape(client, auth_headers): """search endpoint returns TicketListResponse shape when no PSA connected.""" response = await client.get( "/api/v1/integrations/psa/tickets/search", headers=auth_headers, ) # No PSA connection → 400 assert response.status_code in (200, 400, 502) if response.status_code == 200: data = response.json() assert "items" in data assert "total" in data assert "page" in data @pytest.mark.asyncio async def test_update_status_requires_auth(client): response = await client.patch( "/api/v1/integrations/psa/tickets/1/status?status_id=5" ) assert response.status_code == 401 @pytest.mark.asyncio async def test_ai_parse_requires_auth(client): response = await client.post( "/api/v1/integrations/psa/tickets/ai-parse", json={"prompt": "New ticket for Acme"}, ) assert response.status_code == 401 ``` - [ ] **Step 3: Run tests** ```bash cd backend && pytest tests/test_psa_tickets.py -v --override-ini="addopts=" ``` Expected: all pass. - [ ] **Step 4: Commit** ```bash git add backend/app/services/assistant_chat_service.py backend/tests/test_psa_tickets.py git commit -m "feat(psa): add spin-off ticket system prompt rule, backend routing tests" ``` --- ## Phase 3 — Frontend: Types + API Client ### Task 9: Create types/tickets.ts + update types/integrations.ts **Files:** - Create: `frontend/src/types/tickets.ts` - Modify: `frontend/src/types/integrations.ts` - [ ] **Step 1: Create types/tickets.ts** ```typescript // frontend/src/types/tickets.ts import type { PSATicketSearchResult } from '@/types/integrations' export interface TicketFilters { search: string board_id: number | null status_id: number | null priority: string | null company_id: number | null assigned: 'me' | 'unassigned' | 'all' | number include_closed: boolean } export const DEFAULT_TICKET_FILTERS: TicketFilters = { search: '', board_id: null, status_id: null, priority: null, company_id: null, assigned: 'all', include_closed: false, } export interface TicketCreationPayload { summary: string company_id: number | null board_id: number | null status_id: number | null priority_id: number | null description: string assigned_member_id: number | null } export interface AiParseResponse { summary: string | null company_id: number | null board_id: number | null priority_id: number | null status_id: number | null assigned_member_id: number | null description: string | null missing_fields: string[] warnings: string[] } export interface PSAResource { member_id: number member_name: string member_identifier: string is_rf_user: boolean } export interface PSATicketCreated { id: number summary: string board_name: string status_name: string priority_name: string company_name: string resources: PSAResource[] } export interface PSATicketStatusUpdate { ticket_id: number previous_status: string new_status: string } export interface TicketListResponse { items: PSATicketSearchResult[] total: number page: number page_size: number } export interface PSAPriority { id: number name: string } ``` - [ ] **Step 2: Update PSATicketSearchResult and PSATicketInfo in types/integrations.ts** ```typescript // frontend/src/types/integrations.ts — update these interfaces export interface PSATicketInfo { id: string summary: string company_name: string | null company_id: number | null // add board_name: string | null board_id: number | null // add status_name: string | null status_id: number | null // add priority_name: string | null priority_id: number | null // add } export interface PSATicketSearchResult { id: string summary: string company_name: string | null company_id: string | null // add board_name: string | null board_id: number | null // add status_name: string | null status_id: number | null // add priority_name: string | null priority_id: number | null // add closed: boolean } ``` - [ ] **Step 3: Export new types from types/index.ts (if it exists)** ```bash grep -n "tickets" /home/coder/root-workspace/resolutionflow/frontend/src/types/index.ts ``` If `types/index.ts` exists and exports from other type files, add: ```typescript export * from './tickets' ``` - [ ] **Step 4: Commit** ```bash git add frontend/src/types/tickets.ts frontend/src/types/integrations.ts git commit -m "feat(tickets): add tickets types, expand PSATicketSearchResult/PSATicketInfo with IDs" ``` --- ### Task 10: Create api/tickets.ts + update api/integrations.ts **Files:** - Create: `frontend/src/api/tickets.ts` - Modify: `frontend/src/api/integrations.ts` - [ ] **Step 1: Create api/tickets.ts** ```typescript // frontend/src/api/tickets.ts import { apiClient } from './client' import type { PSAResource, PSATicketCreated, PSATicketStatusUpdate, TicketCreationPayload, AiParseResponse, TicketListResponse, PSAPriority, } from '@/types/tickets' export const ticketsApi = { listResources: (ticketId: number): Promise => apiClient.get(`/integrations/psa/tickets/${ticketId}/resources`).then(r => r.data), addResource: (ticketId: number, memberId: number): Promise => apiClient.post(`/integrations/psa/tickets/${ticketId}/resources?member_id=${memberId}`).then(r => r.data), removeResource: (ticketId: number, memberId: number): Promise => apiClient.delete(`/integrations/psa/tickets/${ticketId}/resources/${memberId}`).then(() => undefined), updateStatus: (ticketId: number, statusId: number): Promise => apiClient.patch(`/integrations/psa/tickets/${ticketId}/status?status_id=${statusId}`).then(r => r.data), createTicket: (payload: TicketCreationPayload): Promise => apiClient.post('/integrations/psa/tickets', payload).then(r => r.data), aiParse: (prompt: string): Promise => apiClient.post('/integrations/psa/tickets/ai-parse', { prompt }).then(r => r.data), listPriorities: (): Promise => apiClient.get('/integrations/psa/priorities').then(r => r.data), searchTickets: (params: { query?: string board_id?: number | null status_id?: number | null include_closed?: boolean assigned_to_me?: boolean unassigned?: boolean board_ids?: string priority?: string | null company_id?: number | null page?: number page_size?: number }): Promise => apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), } ``` - [ ] **Step 2: Update return types in api/integrations.ts** ```typescript // frontend/src/api/integrations.ts — update these two methods: import type { TicketListResponse } from '@/types/tickets' searchTickets: (params: { query?: string; board_id?: number; include_closed?: boolean }) => apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), searchTicketsQueue: (params: { assigned_to_me?: boolean unassigned?: boolean board_ids?: string page?: number page_size?: number }) => apiClient.get('/integrations/psa/tickets/search', { params }).then(r => r.data), ``` - [ ] **Step 3: Update TicketQueue.tsx and TicketPickerModal.tsx to read .items** In `frontend/src/components/dashboard/TicketQueue.tsx`, find every place that uses the result of `searchTicketsQueue()` or `searchTickets()` as an array and change it to read `.items`: ```bash grep -n "searchTicketsQueue\|searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/dashboard/TicketQueue.tsx ``` Change any `setTickets(data)` or similar to `setTickets(data.items)`. In `frontend/src/components/session/TicketPickerModal.tsx`: ```bash grep -n "searchTickets" /home/coder/root-workspace/resolutionflow/frontend/src/components/session/TicketPickerModal.tsx ``` Change any usage from array result to `.items` access. - [ ] **Step 4: Commit** ```bash git add frontend/src/api/tickets.ts frontend/src/api/integrations.ts \ frontend/src/components/dashboard/TicketQueue.tsx \ frontend/src/components/session/TicketPickerModal.tsx git commit -m "feat(tickets): add tickets API client, update integrations API for paginated search" ``` --- ## Phase 4 — Tickets Page ### Task 11: Create TicketFilterBar.tsx **Files:** - Create: `frontend/src/components/tickets/TicketFilterBar.tsx` - [ ] **Step 1: Create the config-driven filter bar** ```tsx // frontend/src/components/tickets/TicketFilterBar.tsx import { Search, X } from 'lucide-react' import { cn } from '@/lib/utils' import type { TicketFilters } from '@/types/tickets' import type { PSABoard, PSATicketStatusItem } from '@/types/integrations' import type { PSAPriority } from '@/types/tickets' interface TicketFilterBarProps { filters: TicketFilters onChange: (updated: Partial) => void boards: PSABoard[] statuses: PSATicketStatusItem[] priorities: PSAPriority[] members: { id: number; name: string }[] total: number page: number pageSize: number onPageChange: (page: number) => void loading: boolean } export function TicketFilterBar({ filters, onChange, boards, statuses, priorities, members, total, page, pageSize, onPageChange, loading, }: TicketFilterBarProps) { const start = (page - 1) * pageSize + 1 const end = Math.min(page * pageSize, total) const hasNext = page * pageSize < total const hasPrev = page > 1 return (
{/* Filter row */}
{/* Search */}
onChange({ search: e.target.value })} />
{/* Assignment */} {/* Board */} {/* Status */} {/* Priority */} {/* Include closed */} {/* Clear filters */} {(filters.search || filters.board_id || filters.status_id || filters.priority || filters.assigned !== 'all' || filters.include_closed) && ( )}
{/* Pagination row */} {total > 0 && (
{loading ? 'Loading…' : `Showing ${start}–${end} of ${total} tickets`}
)}
) } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/tickets/TicketFilterBar.tsx git commit -m "feat(tickets): add config-driven TicketFilterBar with pagination controls" ``` --- ### Task 12: Create TicketListRow.tsx **Files:** - Create: `frontend/src/components/tickets/TicketListRow.tsx` - [ ] **Step 1: Create compact row component** ```tsx // frontend/src/components/tickets/TicketListRow.tsx import { cn } from '@/lib/utils' import type { PSATicketSearchResult } from '@/types/integrations' interface TicketListRowProps { ticket: PSATicketSearchResult selected: boolean onClick: () => void } const PRIORITY_STYLES: Record = { Critical: 'text-danger', High: 'text-danger', Medium: 'text-warning', Low: 'text-muted-foreground', } const STATUS_STYLES: Record = { New: { bg: 'bg-accent/10', text: 'text-accent' }, 'In Progress': { bg: 'bg-warning/10', text: 'text-warning' }, Waiting: { bg: 'bg-success/10', text: 'text-success' }, Resolved: { bg: 'bg-muted/10', text: 'text-muted-foreground' }, } function statusStyle(name: string | null) { if (!name) return { bg: 'bg-elevated', text: 'text-muted-foreground' } return STATUS_STYLES[name] ?? { bg: 'bg-elevated', text: 'text-muted-foreground' } } export function TicketListRow({ ticket, selected, onClick }: TicketListRowProps) { const { bg, text } = statusStyle(ticket.status_name) const priorityClass = PRIORITY_STYLES[ticket.priority_name ?? ''] ?? 'text-muted-foreground' return (
e.key === 'Enter' && onClick()} className={cn( 'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-default text-sm', selected ? 'bg-accent/5' : 'hover:bg-elevated' )} > {/* ID */} #{ticket.id} {/* Summary */} {ticket.summary} {/* Company */} {ticket.company_name ?? '—'} {/* Board */} {ticket.board_name ?? '—'} {/* Status badge */} {ticket.status_name ?? '—'} {/* Priority */} {ticket.priority_name ?? '—'}
) } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/tickets/TicketListRow.tsx git commit -m "feat(tickets): add compact TicketListRow component" ``` --- ### Task 13: Create TicketsPage.tsx **Files:** - Create: `frontend/src/pages/TicketsPage.tsx` - [ ] **Step 1: Create TicketsPage with URL-param filter state** ```tsx // frontend/src/pages/TicketsPage.tsx import { useEffect, useState, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { Plus, Ticket } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { TicketFilterBar } from '@/components/tickets/TicketFilterBar' import { TicketListRow } from '@/components/tickets/TicketListRow' import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel' import { NewTicketModal } from '@/components/tickets/NewTicketModal' import { integrationsApi } from '@/api/integrations' import { ticketsApi } from '@/api/tickets' import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations' import type { TicketFilters, PSAPriority } from '@/types/tickets' import { DEFAULT_TICKET_FILTERS } from '@/types/tickets' const PAGE_SIZE = 25 function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } { const assigned = params.get('assigned') ?? 'all' return { search: params.get('search') ?? '', board_id: params.get('board') ? Number(params.get('board')) : null, status_id: params.get('status') ? Number(params.get('status')) : null, priority: params.get('priority') ?? null, company_id: params.get('company') ? Number(params.get('company')) : null, assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all') ? assigned : Number(assigned), include_closed: params.get('closed') === 'true', page: params.get('page') ? Number(params.get('page')) : 1, } } export default function TicketsPage() { const [searchParams, setSearchParams] = useSearchParams() const { page, ...filters } = filtersFromParams(searchParams) const [tickets, setTickets] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(false) const [boards, setBoards] = useState([]) const [statuses, setStatuses] = useState([]) const [priorities, setPriorities] = useState([]) const [members, setMembers] = useState<{ id: number; name: string }[]>([]) const [selectedTicket, setSelectedTicket] = useState(null) const [showNewTicket, setShowNewTicket] = useState(false) // Load filter option data once useEffect(() => { integrationsApi.listBoards().then(setBoards).catch(() => {}) ticketsApi.listPriorities().then(setPriorities).catch(() => {}) integrationsApi.listMembers() .then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name })))) .catch(() => {}) }, []) // Load statuses when board changes useEffect(() => { if (filters.board_id) { integrationsApi.getTicketStatuses(String(filters.board_id)) .then(setStatuses).catch(() => {}) } else { setStatuses([]) } }, [filters.board_id]) // Fetch tickets on filter/page change const fetchTickets = useCallback(async () => { setLoading(true) try { const result = await ticketsApi.searchTickets({ query: filters.search || undefined, board_id: filters.board_id ?? undefined, status_id: filters.status_id ?? undefined, include_closed: filters.include_closed, assigned_to_me: filters.assigned === 'me', unassigned: filters.assigned === 'unassigned', priority: filters.priority ?? undefined, company_id: filters.company_id ?? undefined, page, page_size: PAGE_SIZE, }) setTickets(result.items) setTotal(result.total) } catch { setTickets([]) setTotal(0) } finally { setLoading(false) } }, [filters.search, filters.board_id, filters.status_id, filters.include_closed, filters.assigned, filters.priority, filters.company_id, page]) useEffect(() => { fetchTickets() }, [fetchTickets]) function updateFilters(updated: Partial) { const next = new URLSearchParams(searchParams) if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search') if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board') if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status') if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority') if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company') if ('assigned' in updated) { const a = updated.assigned a === 'all' ? next.delete('assigned') : next.set('assigned', String(a)) } if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed') next.delete('page') // reset to 1 on filter change setSearchParams(next) } function updatePage(p: number) { const next = new URLSearchParams(searchParams) p === 1 ? next.delete('page') : next.set('page', String(p)) setSearchParams(next) } return (
{/* Header */}

Tickets

{/* Filters */}
{/* List + Detail Panel */}
{/* Ticket list */}
{loading && tickets.length === 0 && (
Loading tickets…
)} {!loading && tickets.length === 0 && (
No tickets match your filters
)} {tickets.map(t => ( setSelectedTicket(t)} /> ))}
{/* Detail panel */} {selectedTicket && (
setSelectedTicket(null)} onStatusUpdated={(ticketId, newStatus) => { setTickets(prev => prev.map(t => t.id === String(ticketId) ? { ...t, status_name: newStatus } : t )) }} />
)}
{/* New Ticket Modal */} {showNewTicket && ( setShowNewTicket(false)} onCreated={() => { setShowNewTicket(false); fetchTickets() }} /> )}
) } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/pages/TicketsPage.tsx git commit -m "feat(tickets): add TicketsPage with URL-param filter state and slide-out detail" ``` --- ### Task 14: Add route + sidebar nav **Files:** - Modify: `frontend/src/router.tsx` - Modify: `frontend/src/components/layout/Sidebar.tsx` - [ ] **Step 1: Add TicketsPage to router.tsx** After the existing lazy imports, add: ```typescript const TicketsPage = lazyWithRetry(() => import('@/pages/TicketsPage')) ``` Inside the protected route children array (alongside other routes like `/sessions`), add: ```typescript { path: 'tickets', element: }> }, ``` - [ ] **Step 2: Add Tickets nav item to Sidebar.tsx** In `Sidebar.tsx`, find the `railGroups` array. Add a Tickets entry in the RESOLVE section. Find the History entry: ```typescript { href: '/sessions', icon: History, label: 'History', shortLabel: 'History', ... }, ``` Add after it: ```typescript { href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'], }, ``` Also add `Ticket` to the Lucide imports at the top of `Sidebar.tsx`. - [ ] **Step 3: Commit** ```bash git add frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx git commit -m "feat(tickets): add /tickets route and sidebar nav item" ``` --- ## Phase 5 — Ticket Detail Panel ### Task 15: Create detail subcomponents **Files:** - Create: `frontend/src/components/tickets/detail/TicketDetailHeader.tsx` - Create: `frontend/src/components/tickets/detail/TicketNotesFeed.tsx` - Create: `frontend/src/components/tickets/detail/TicketAddNote.tsx` - Create: `frontend/src/components/tickets/detail/TicketConfigs.tsx` - Create: `frontend/src/components/tickets/detail/TicketRelated.tsx` - [ ] **Step 1: Create TicketDetailHeader.tsx** ```tsx // frontend/src/components/tickets/detail/TicketDetailHeader.tsx import { ExternalLink } from 'lucide-react' import type { PSATicketSearchResult } from '@/types/integrations' import type { PSATicketStatusUpdate } from '@/types/tickets' import type { PSATicketStatusItem } from '@/types/integrations' import { ticketsApi } from '@/api/tickets' import { toast } from '@/lib/toast' import { useState } from 'react' interface Props { ticket: PSATicketSearchResult statuses: PSATicketStatusItem[] onStatusUpdated: (ticketId: number, newStatus: string) => void } export function TicketDetailHeader({ ticket, statuses, onStatusUpdated }: Props) { const [updating, setUpdating] = useState(false) async function handleStatusChange(statusId: number) { if (!ticket.id) return setUpdating(true) try { const result: PSATicketStatusUpdate = await ticketsApi.updateStatus(Number(ticket.id), statusId) onStatusUpdated(result.ticket_id, result.new_status) toast.success(`Status updated to ${result.new_status}`) } catch { toast.error('Failed to update status') } finally { setUpdating(false) } } return (
#{ticket.id} {ticket.board_name && ( {ticket.board_name} )}

{ticket.summary}

{ticket.company_name && (

{ticket.company_name}

)}
{/* Status + Priority */}
{statuses.length > 0 ? ( ) : ( ticket.status_name && ( {ticket.status_name} ) )} {ticket.priority_name && ( {ticket.priority_name} )}
) } ``` - [ ] **Step 2: Create TicketNotesFeed.tsx** ```tsx // frontend/src/components/tickets/detail/TicketNotesFeed.tsx import type { TicketNote } from '@/api/psaContext' interface Props { notes: TicketNote[] } export function TicketNotesFeed({ notes }: Props) { if (notes.length === 0) { return

No notes yet.

} return (
{notes.map((note, i) => (
{note.member ?? 'Unknown'} {new Date(note.date_created).toLocaleDateString()}
{note.internal_analysis_flag && ( Internal )}

{note.text}

))}
) } ``` - [ ] **Step 3: Create TicketAddNote.tsx** ```tsx // frontend/src/components/tickets/detail/TicketAddNote.tsx import { useState } from 'react' import { sessionPsaApi } from '@/api/integrations' import { toast } from '@/lib/toast' interface Props { ticketId: string sessionId?: string onPosted: () => void } export function TicketAddNote({ ticketId, sessionId, onPosted }: Props) { const [text, setText] = useState('') const [noteType, setNoteType] = useState<'internal_analysis' | 'description'>('internal_analysis') const [posting, setPosting] = useState(false) // Note posting requires a session link — if no session, show info if (!sessionId) { return (

Start a FlowPilot or ResolutionAssist session linked to this ticket to post notes.

) } async function handlePost() { if (!text.trim()) return setPosting(true) try { await sessionPsaApi.postToTicket(sessionId!, { note_type: noteType, update_status_id: undefined, }) setText('') toast.success('Note posted to ticket') onPosted() } catch { toast.error('Failed to post note') } finally { setPosting(false) } } return (