feat: mid-session status updates — ticket notes, client updates, email drafts

Engineers can now generate AI-powered status updates during active FlowPilot
sessions and after resolve/escalate. Three audiences (Ticket Notes, Client
Update, Email Draft) with Quick/Detailed length options. Copy to clipboard
with one click. Client names auto-inserted from intake/PSA context.

Backend: new endpoint POST /ai-sessions/{id}/status-update with audience-aware
system prompts. Frontend: StatusUpdateModal with 2-step selection flow,
Share Update button in action bar, Share Resolution/Escalation on completed
sessions. Also updates Solutions Library spec with Community tier design.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 06:26:32 +00:00
parent 0d78410dea
commit fab25456a5
13 changed files with 1560 additions and 5 deletions

View File

@@ -36,6 +36,8 @@ from app.schemas.ai_session import (
SessionCloseResponse,
SessionDocumentation,
RateSessionRequest,
StatusUpdateRequest,
StatusUpdateResponse,
PickupSessionRequest,
LinkTicketRequest,
AISessionSummary,
@@ -733,6 +735,31 @@ async def get_documentation(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
# ── Status Update ──
@router.post("/{session_id}/status-update", response_model=StatusUpdateResponse)
@limiter.limit("20/minute")
async def create_status_update(
request: Request,
session_id: UUID,
data: StatusUpdateRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Generate a status update for ticket notes, client, or email."""
try:
return await flowpilot_engine.generate_status_update(
session_id=session_id,
request=data,
user_id=current_user.id,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
# ── Rate ──
@router.post("/{session_id}/rate", status_code=204)

View File

@@ -137,6 +137,38 @@ class SessionCloseResponse(BaseModel):
member_mapping_warning: str | None = None
class StatusUpdateRequest(BaseModel):
"""Generate a mid-session or post-session status update."""
audience: str = Field(
...,
pattern="^(ticket_notes|client_update|email_draft)$",
description="Who is this update for?",
)
length: str = Field(
"detailed",
pattern="^(quick|detailed)$",
description="Quick (1-2 sentences) or detailed breakdown",
)
context: str = Field(
"status",
pattern="^(status|resolution|escalation)$",
description="What type of communication: mid-session status, resolution close-out, or escalation handoff",
)
class StatusUpdateResponse(BaseModel):
"""Generated status update content."""
content: str
audience: str
length: str
context: str
session_status: str
steps_completed: int
time_spent_display: str | None = None
client_name: str | None = None
generated_at: datetime
class RateSessionRequest(BaseModel):
"""Submit post-session rating."""
rating: int = Field(..., ge=1, le=5)

View File

@@ -34,6 +34,8 @@ from app.schemas.ai_session import (
SessionCloseResponse,
SessionDocumentation,
DocumentationStep,
StatusUpdateRequest,
StatusUpdateResponse,
)
logger = logging.getLogger(__name__)
@@ -840,6 +842,237 @@ async def get_session_documentation(
return _generate_documentation(session)
async def generate_status_update(
session_id: UUID,
request: StatusUpdateRequest,
user_id: UUID,
db: AsyncSession,
) -> StatusUpdateResponse:
"""Generate a status update for ticket notes, client communication, or email draft."""
session = await _load_session(session_id, user_id, db)
# Build conversation summary from session steps
steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {}
text = content.get("text", "")
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None)
outcome = None
if step.action_result:
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
entry = f"Step {step.step_order + 1}: {text}"
if response:
entry += f"\n Engineer response: {response}"
if outcome:
entry += f"\n Outcome: {outcome}"
steps_summary.append(entry)
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
# Calculate time spent
now = datetime.now(timezone.utc)
ref_time = session.resolved_at or now
delta = ref_time - session.created_at
total_minutes = int(delta.total_seconds() / 60)
if total_minutes < 60:
time_display = f"{total_minutes} minutes"
else:
hours = total_minutes // 60
remaining = total_minutes % 60
time_display = f"{hours}h {remaining}m"
# Extract client name from intake or ticket data
client_name = None
intake = session.intake_content or {}
if session.ticket_data:
company = session.ticket_data.get("company", {})
client_name = company.get("name") if isinstance(company, dict) else None
if not client_name:
client_name = intake.get("client_name") or intake.get("company_name")
# Get engineer name for sign-off
engineer_name = session.user.name if session.user and session.user.name else "Your support team"
# Build system prompt based on audience and context
system_prompt = _build_status_update_prompt(
audience=request.audience,
length=request.length,
context=request.context,
client_name=client_name,
engineer_name=engineer_name,
)
# Build user message with full session context
user_message = _build_status_update_context(
session=session,
steps_text=steps_text,
time_display=time_display,
context=request.context,
client_name=client_name,
)
provider = get_ai_provider(settings.get_model_for_action("quick_action"))
raw_response, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=[{"role": "user", "content": user_message}],
max_tokens=1500,
)
# Track token usage
session.total_input_tokens = (session.total_input_tokens or 0) + input_tokens
session.total_output_tokens = (session.total_output_tokens or 0) + output_tokens
# Store as a session step
step = AISessionStep(
id=uuid.uuid4(),
session_id=session.id,
step_order=session.step_count,
step_type="status_update",
content={
"audience": request.audience,
"length": request.length,
"context": request.context,
"generated_content": raw_response.strip(),
"client_name": client_name,
},
confidence_score=1.0,
confidence_tier="high",
)
db.add(step)
session.step_count += 1
await db.flush()
return StatusUpdateResponse(
content=raw_response.strip(),
audience=request.audience,
length=request.length,
context=request.context,
session_status=session.status,
steps_completed=len(steps_summary),
time_spent_display=time_display,
client_name=client_name,
generated_at=now,
)
def _build_status_update_prompt(
audience: str,
length: str,
context: str,
client_name: str | None,
engineer_name: str,
) -> str:
"""Build the system prompt for status update generation."""
length_instruction = (
"Keep it to 1-2 sentences maximum. Just the essentials."
if length == "quick"
else "Provide a full breakdown with steps completed, findings, and next steps."
)
context_labels = {
"status": "mid-session progress update",
"resolution": "resolution close-out summary",
"escalation": "escalation handoff note",
}
context_label = context_labels.get(context, "status update")
if audience == "ticket_notes":
return f"""You are generating an internal {context_label} for a PSA ticket note.
Rules:
- Be technical, concise, and factual
- Use markdown formatting (bold headers, bullet lists)
- Include: current status, steps completed, findings, what's been ruled out, next steps
- Do NOT soften language or add pleasantries
- Do NOT include greetings or sign-offs
- {length_instruction}
{"- Include root cause and resolution details since this is a close-out note" if context == "resolution" else ""}
{"- Include what was tried, what failed, and why this is being escalated" if context == "escalation" else ""}
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "client_update":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a client-facing {context_label}.
Rules:
- Be professional, reassuring, and non-technical
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.)
- NEVER include server names, IP addresses, internal tool names, or technical identifiers
- Explain findings in plain language a non-technical business owner would understand
- {client_greeting}
- Sign off with: {engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
else: # email_draft
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
subject_hints = {
"status": "Update: [brief issue description]",
"resolution": "Resolved: [brief issue description]",
"escalation": "Update: [brief issue description] — Specialist Review",
}
return f"""You are generating a complete email draft for client communication.
Rules:
- Include a Subject: line at the very top
- Subject format: {subject_hints.get(context, "Update: [issue]")}
- {client_greeting}
- Be professional, reassuring, and non-technical
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- Include a professional sign-off with:
{engineer_name}
- {length_instruction}
{"- This is good news — the issue is resolved." if context == "resolution" else ""}
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
def _build_status_update_context(
session: AISession,
steps_text: str,
time_display: str,
context: str,
client_name: str | None,
) -> str:
"""Build the user message containing full session context for the AI."""
parts = [
f"Session status: {session.status}",
f"Time spent: {time_display}",
f"Problem summary: {session.problem_summary or 'Not yet determined'}",
]
if session.problem_domain:
parts.append(f"Problem domain: {session.problem_domain}")
if client_name:
parts.append(f"Client: {client_name}")
if session.psa_ticket_id:
parts.append(f"Ticket ID: {session.psa_ticket_id}")
parts.append(f"\nDiagnostic steps:\n{steps_text}")
if context == "resolution" and session.resolution_summary:
parts.append(f"\nResolution: {session.resolution_summary}")
if context == "escalation" and session.escalation_reason:
parts.append(f"\nEscalation reason: {session.escalation_reason}")
# Include recent conversation messages for richer context
messages = session.conversation_messages or []
if messages:
recent = messages[-10:] # Last 10 messages
convo_text = "\n".join(
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
for m in recent
if isinstance(m, dict) and "role" in m and "content" in m
)
parts.append(f"\nRecent conversation:\n{convo_text}")
return "\n".join(parts)
# ── Internal helpers ──
async def _load_session(

View File

@@ -0,0 +1,601 @@
# Mid-Session Status Updates — Feature Spec
> **Status:** IMPLEMENT NOW
> **Date:** 2026-03-23
> **Priority:** High — addresses real MSP workflow need during active troubleshooting
---
## Problem
Engineers are mid-ticket, actively troubleshooting in FlowPilot, and need to share progress. Today they have to:
1. Context-switch out of FlowPilot
2. Mentally summarize what they've done
3. Write the update themselves (different tone for ticket notes vs. client emails)
4. Paste it into their PSA or email
This breaks flow, wastes time, and produces inconsistent documentation. The AI already has full context — it should generate the update.
## Solution
**"Share Update"** — a button in the FlowPilot action bar that generates a context-aware status summary, tailored for either internal ticket notes or client-facing communication.
---
## User Flow
### 1. Trigger
Two ways to trigger:
- **Action bar button:** "Share Update" button (blue/cyan, positioned between Escalate and Pause)
- **Chat command:** Engineer types "status update", "give me an update", "share progress" — FlowPilot recognizes the intent
### 2. Two-Step Selection
**Step 1 — Audience:**
FlowPilot responds:
> *"Who is this update for?"*
>
> **[Ticket Notes]** — Technical, for your PSA
> **[Client Update]** — Professional, non-technical
> **[Email Draft]** — Full email with subject line and sign-off
These are rendered as clickable option cards in the modal (or inline buttons in chat).
**Step 2 — Length:**
> *"How detailed?"*
>
> **[Quick]** — 1-2 sentences, just the essentials
> **[Detailed]** — Full breakdown with steps and findings
Both steps are single-click. If the engineer triggers via chat with a specific phrase (e.g., "quick update for the client"), both steps are skipped.
### 3. AI Generates Summary
Based on the full session context (all messages, steps tried, current diagnosis), AI generates the appropriate summary.
#### Ticket Notes Mode
- **Tone:** Technical, concise, factual (customizable per team — see Team Templates below)
- **Format:** PSA-compatible markdown (ConnectWise supports markdown in notes)
- **Content includes:**
- Current status (investigating / identified / implementing fix)
- Steps completed and findings
- What's been ruled out
- Current hypothesis / next steps
- Time spent so far
**Example output (Detailed):**
```
**Status: Investigating**
**Time spent: 22 minutes**
**Steps completed:**
- Verified MX records — correct (pointing to Exchange Online)
- Ran message trace in EAC — emails queuing at transport layer
- Checked recipient mailbox — not full, no forwarding rules
- Reviewed mail flow rules — found suspicious tenant-wide transport rule
**Current diagnosis:**
Transport rule "Block External Senders" appears to be scoped tenant-wide instead of per-group. This is likely blocking all external inbound mail.
**Next steps:**
- Confirm rule scope with client before modifying
- Test with a single mailbox first if client approves
```
**Example output (Quick):**
```
Investigating email delivery issue — 22 min in. Traced to a tenant-wide transport rule blocking external senders. Need client approval to modify scope.
```
#### Client Update Mode
- **Tone:** Professional, reassuring, non-technical (customizable per team — see Team Templates below)
- **Format:** Plain text (suitable for portal message or pasting into chat)
- **Content includes:**
- What we're working on (plain language)
- What we've found so far
- What we're doing next
- Expected next update time (if inferable)
- **Content NEVER includes:**
- Technical jargon (no "transport rules", "MX records", "EAC")
- Server names, IP addresses, internal tool names
- Anything that would confuse a non-technical stakeholder
- **Client name auto-insertion:** If the session has a client/company from intake fields or PSA ticket context, the update addresses them by name ("Hi Acme Medical Group" not generic "Hi")
#### Email Draft Mode
- **Tone:** Same as Client Update but wrapped in full email structure
- **Format:** Complete email ready to paste into Outlook/Gmail
- **Content includes:**
- Subject line (e.g., "Update: Email delivery issue — [Ticket #]")
- Greeting with client name (if available)
- Body (same content as Client Update)
- Professional sign-off with engineer's name (from user profile)
- **Use case:** Many MSPs still communicate via email, not PSA portals
**Example output (Detailed):**
```text
Hi Acme Medical Group,
We're actively working on the email delivery issue. Here's where we stand:
We've confirmed that your email system's core settings are correct — the issue isn't with your email addresses or mailboxes. We've traced it to a mail routing configuration that's preventing incoming emails from being delivered.
We've identified the specific setting that needs to be adjusted and will confirm the change with you before making it. Once approved, we expect delivery to resume within minutes.
We'll update you again shortly.
Best regards,
Michael
```
**Example output (Quick):**
```text
Hi Acme Medical Group — quick update on the email issue. We've identified the cause and have a fix ready. Just need your approval before making the change. Expect resolution within 15 minutes after that. We'll follow up shortly.
```
**Example output (Email Draft):**
```text
Subject: Update: Email delivery issue — TKT-2847
Hi Acme Medical Group,
Just a quick update on the email delivery issue you reported.
We've completed our investigation and identified the cause — a mail routing configuration is preventing incoming emails from being delivered. We have a fix ready and just need your approval before making the change.
Once approved, we expect email delivery to resume within minutes.
Please let us know if you have any questions.
Best regards,
Michael Chihlas
ResolutionFlow
```
### 4. Actions After Generation
The summary appears in the chat as a formatted message with action buttons:
| Button | Action |
| ------ | ------ |
| **Copy** | Copy to clipboard (toast: "Copied to clipboard") |
| **Post to Ticket** | Push directly to ConnectWise ticket as a note (only visible if PSA connected + ticket linked) |
| **Regenerate** | Generate a new version (same audience + length) |
| **Switch Audience** | Switch between ticket notes ↔ client update ↔ email draft |
| **Switch Length** | Toggle quick ↔ detailed |
| **Set Reminder** | Schedule a follow-up reminder (see Follow-up Reminders below) |
**Post to Ticket details:**
- Ticket Notes → posted as **Internal Note** (`internalAnalysisFlag: true`)
- Client Update → posted as **External Note** (`internalAnalysisFlag: false`) — visible to client in their portal
- Uses existing `POST /service/tickets/{id}/notes` ConnectWise endpoint
- Uses existing PSA connection and member mapping from the session
### 5. Multiple Updates Per Session
Engineers can request status updates multiple times during a session. Each update:
- Uses the full conversation context up to that point
- References what changed since the last update (if applicable)
- Gets saved in the session message history (so it's part of the final documentation)
---
## Backend Implementation
### New Endpoint
```
POST /ai-sessions/{session_id}/status-update
```
**Request body:**
```json
{
"audience": "ticket_notes" | "client_update"
}
```
**Response body:**
```json
{
"content": "string — the generated update",
"audience": "ticket_notes" | "client_update",
"session_status": "investigating" | "identified" | "implementing",
"steps_completed": 5,
"time_spent_minutes": 22,
"generated_at": "2026-03-23T14:30:00Z"
}
```
### FlowPilot Engine Addition
Add `generate_status_update()` to `flowpilot_engine.py`:
- Loads session + all steps/messages
- Builds a status-update-specific system prompt based on audience
- Calls the AI model with full conversation context
- Returns formatted update
**System prompt guidance (ticket notes):**
```
You are generating an internal status update for a PSA ticket note.
Be technical, concise, and factual. Use markdown formatting.
Include: current status, steps completed, findings, what's been ruled out, next steps.
Do NOT soften language or add pleasantries.
```
**System prompt guidance (client update):**
```
You are generating a client-facing status update.
Be professional, reassuring, and non-technical.
NEVER use technical jargon, server names, IP addresses, or internal tool names.
Explain findings in plain language a non-technical business owner would understand.
Include: what we're working on, what we've found, what's next.
Keep it brief — 3-5 short paragraphs maximum.
```
### PSA Push (ConnectWise)
Reuse existing infrastructure from session resolution:
- `services/psa/connectwise/client.py` already has `create_ticket_note()`
- Add a new helper or reuse existing: `push_status_update_to_psa(session, content, is_internal)`
- `internalAnalysisFlag` = `true` for ticket notes, `false` for client updates
### Data Model
No new tables needed. Status updates are stored as regular session messages:
- `ai_session_steps` with `step_type = 'status_update'`
- `content` JSONB includes: `{ audience, generated_content, psa_push_status }`
This keeps updates in the session timeline and they'll appear in the final documentation.
---
## Frontend Implementation
### FlowPilotActionBar Changes
Add "Share Update" button to the action bar between Escalate and Pause:
```tsx
<button
onClick={onShareUpdate}
className="flex items-center gap-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 px-4 py-2 min-h-[44px] text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<FileText size={16} />
Share Update
</button>
```
### StatusUpdateModal Component
New modal that handles the audience selection + generated output:
**State 1: Audience Selection**
- Title: "Share Status Update"
- Two large option cards: "Ticket Notes" and "Client Update"
- Each card has icon, title, description
**State 2: Generating**
- Loading spinner with "Generating update..."
- Shows audience selected
**State 3: Result**
- Formatted preview of the generated update
- Action buttons: Copy, Post to Ticket (conditional), Regenerate, Switch Audience
- "Post to Ticket" only visible when `hasPsaTicket` is true
### Chat Integration
When triggered via chat (engineer types "status update"):
- FlowPilot detects the intent in the normal message flow
- Responds with audience selection buttons inline (same as existing action buttons pattern)
- After selection, generates and displays the update in the chat stream
- Copy/Post to Ticket buttons appear below the update message
### API Client
Add to `frontend/src/api/aiSessions.ts`:
```typescript
generateStatusUpdate: (sessionId: string, audience: 'ticket_notes' | 'client_update') =>
apiClient.post(`/ai-sessions/${sessionId}/status-update`, { audience })
```
### Hook Extension
Add to `useFlowPilotSession`:
```typescript
generateStatusUpdate: async (audience: 'ticket_notes' | 'client_update') => {
const result = await aiSessionsApi.generateStatusUpdate(sessionId, audience)
return result
}
```
---
## Chat Intent Detection
Add status update detection to the FlowPilot engine's intent recognition:
**Trigger phrases:**
- "status update"
- "give me an update"
- "share progress"
- "write a summary"
- "update for the ticket"
- "update for the client"
- "what should I tell the client"
- "ticket note"
If the phrase specifies audience and/or length (e.g., "quick update for the client"), skip those steps and generate directly.
---
## Team Communication Templates
Team admins can customize the default tone and format for status updates via Account Settings → Communication Templates.
### Configurable settings
| Setting | Options | Default |
| ------- | ------- | ------- |
| Client tone | Professional, Friendly, Formal | Professional |
| Client sign-off | Custom text or auto (engineer name) | Auto |
| Company name in sign-off | Include / Exclude | Include |
| Ticket notes format | Markdown, Plain text | Markdown |
| Default length | Quick, Detailed | Detailed |
### Tone examples
**Professional (default):**
> "We've identified the cause and have a fix ready."
**Friendly:**
> "Good news — we've found what's causing this and we're ready to fix it!"
**Formal:**
> "We wish to inform you that we have completed our investigation and identified the root cause of the reported issue."
### Storage
Templates stored in `accounts` table as JSONB column `communication_templates`:
```json
{
"client_tone": "professional",
"client_signoff": "auto",
"include_company_in_signoff": true,
"ticket_notes_format": "markdown",
"default_length": "detailed"
}
```
---
## Follow-up Reminders
After generating a status update, the engineer can set a reminder to send another one.
### Flow
1. After update is generated, "Set Reminder" button appears
2. Quick options: **15 min**, **30 min**, **1 hour**, **Custom**
3. When the timer fires:
- In-app notification: *"Time to send another update to [Client Name] on the email delivery issue"*
- Click notification → opens the session with the Share Update modal pre-loaded
5. If the session was resolved before the reminder fires, auto-dismiss with a note: *"Reminder cancelled — session resolved"*
### Storage
Reminders stored as `ai_session_steps` with `step_type = 'update_reminder'`:
```json
{
"remind_at": "2026-03-23T15:00:00Z",
"audience": "client_update",
"status": "pending" | "fired" | "cancelled"
}
```
Checked via existing APScheduler interval job or a lightweight frontend timer (for v1, frontend `setTimeout` is sufficient since the session is active).
---
## Update History Sidebar
A mini-timeline in the session sidebar showing all status updates sent during the session.
### What it shows
Each entry displays:
- **Audience icon:** clipboard (ticket), user (client), mail (email draft)
- **Timestamp:** "2:15 PM"
- **Length badge:** "Quick" or "Detailed"
- **Delivery status:** Copied / Posted to ticket / Pending reminder
- **Click to expand:** Shows the full generated text
### Location
Inside the existing session sidebar (right side of FlowPilot), below the session info section. Collapsible: "Updates (3)" header that expands to show the timeline.
### Why this matters
Engineers lose track of what they've already communicated, especially on long sessions. This prevents duplicate updates and lets them reference what they told the client earlier.
---
## Resolution Communication
The same audience/length system applies when an engineer **resolves** a session — this is where it matters most.
### Enhanced Resolve Flow
**Current flow:**
1. Engineer clicks Resolve → types summary → auto-generated documentation → done
**Enhanced flow:**
1. Engineer clicks Resolve → types summary → auto-generated documentation
2. **"Share Resolution"** step appears immediately after, with the same options:
- Audience: Ticket Notes / Client Update / Email Draft
- Length: Quick / Detailed
3. Pre-generated based on team's default settings (no extra clicks if defaults are right)
4. Engineer can Copy, Post to Ticket, or skip
### Resolution-Specific Content
#### Ticket Notes (Resolution)
```text
**Status: Resolved**
**Time spent: 35 minutes**
**Root cause: Tenant-wide transport rule blocking external senders**
**Investigation steps:**
- Verified MX records — correct
- Ran message trace — emails queuing at transport layer
- Checked recipient mailbox — no issues
- Identified transport rule "Block External" scoped tenant-wide instead of per-group
**Resolution:**
Modified transport rule scope from tenant-wide to security group "External-Block-Group". Verified mail flow resumed within 5 minutes. Confirmed with end user that emails are now being received.
**Recommendations:**
- Review all tenant-wide transport rules quarterly
- Document rule changes in change management system
```
#### Client Update (Resolution)
```text
Hi Acme Medical Group,
Great news — the email delivery issue is resolved.
We found that a mail routing setting was preventing incoming emails from being delivered to your mailboxes. We've corrected the configuration, and email delivery has been confirmed working.
If you or your team notice any further issues with email, please don't hesitate to reach out.
Best regards,
Michael
```
#### Email Draft (Resolution)
```text
Subject: Resolved: Email delivery issue — TKT-2847
Hi Acme Medical Group,
I'm happy to report that the email delivery issue has been resolved.
The cause was a mail routing configuration that was blocking incoming emails. We've corrected this, and your team should now be receiving emails normally. We verified delivery is working before closing the ticket.
If you notice any further issues, please let us know and we'll investigate immediately.
Best regards,
Michael Chihlas
ResolutionFlow
```
### How It Integrates
- Reuses the exact same `generate_status_update()` backend function, with an additional `context: "resolution"` parameter
- The resolution summary the engineer typed feeds into the generation as the authoritative "what fixed it"
- The existing resolve endpoint (`POST /ai-sessions/{id}/resolve`) returns documentation as before, but now the frontend shows the "Share Resolution" step after
- If ConnectWise is connected, "Post to Ticket" pushes the resolution note alongside the existing auto-documentation push
### Escalation Communication
Same system for escalations — when an engineer escalates, offer:
- **Ticket Notes:** Technical handoff note (what was tried, what failed, why it's being escalated)
- **Client Update:** "We're bringing in a specialist to look at this more closely..."
- **Email Draft:** Full email with escalation context
---
## Implementation Plan
### Phase 1: Core (implement now)
1. **Backend:** `generate_status_update()` in `flowpilot_engine.py` + endpoint in `ai_sessions.py`
2. **Frontend:** `StatusUpdateModal` with 2-step selection (audience + length) + "Share Update" button in action bar
3. **Three audiences:** Ticket Notes, Client Update, Email Draft
4. **Two lengths:** Quick and Detailed
5. **Copy to clipboard** functionality
6. **Client name auto-insertion** from intake fields or PSA ticket context
7. **Store as session step** (`step_type = 'status_update'`)
8. **Resolution communication** — "Share Resolution" step after resolve, same audience/length options
9. **Escalation communication** — "Share Escalation" step after escalate, same options
### Phase 2: Update History + Reminders
1. **Update history sidebar** — mini-timeline of all updates/resolutions sent during session
2. **Follow-up reminders** — set timer after sending update, in-app notification when it fires
3. **Auto-dismiss reminders** when session resolves before timer
### Phase 3: Team Templates
1. **Communication Templates** in Account Settings — tone, sign-off, format preferences
2. **Template-aware generation** — system prompts incorporate team's configured tone/style
### Phase 4: PSA Push (implement with ConnectWise integration)
1. **Post to Ticket** button — push to ConnectWise as internal or external note
2. **PSA status indicator** — show success/failure after push
### Phase 5: Chat Integration (polish)
1. **Intent detection** — recognize status update requests in natural language
2. **Inline generation** — audience buttons + update rendered in chat stream
3. **"Since last update" awareness** — reference changes since previous status update
4. **Shorthand commands** — "quick update for the client" skips both selection steps
---
## Edge Cases
- **No messages yet:** Disable "Share Update" until at least 2 message exchanges
- **Session paused/resolved:** Still allow generating updates (useful for post-session documentation)
- **No PSA connected:** Hide "Post to Ticket" button, only show Copy
- **PSA push fails:** Show error toast, keep the generated content available for manual copy
- **Multiple rapid requests:** Debounce — disable button for 3 seconds after generation
- **Very short session:** AI should still produce a useful update, even if minimal ("Currently investigating — gathering initial information")
---
## Success Metrics
- % of sessions where "Share Update" is used (target: 30%+ of sessions >10 minutes)
- Time between update generation and ticket note creation (should be <5 seconds with Post to Ticket)
- Net Promoter feedback on update quality (post-pilot survey)

View File

@@ -1,7 +1,7 @@
# Solutions Library + Smart RAG — Design Spec
# Solutions Library + Smart RAG + Community Knowledge — Design Spec
> **Status:** SPEC ONLY — not implementing yet. Build after colleague pilot (Week 3-4).
> **Date:** 2026-03-23
> **Date:** 2026-03-23 (updated 2026-03-23 — added Community tier)
> **Context:** [GTM validation plan](resolutionflow-gtm-design.md) — copilot-first, team knowledge flywheel
---
@@ -209,3 +209,303 @@ Replaces the current Step Library page. Card-based grid with:
5. Do engineers trust AI-generated resolution summaries, or do they want to write their own?
These questions should be answerable after 2-4 weeks of pilot usage.
---
## Community Solutions Library
### Overview
The Solutions Library has three tiers of knowledge:
| Tier | Source | Who sees it |
| ---- | ------ | ----------- |
| **Private** | Your team's resolutions | Your team only |
| **Community** | Anonymized resolutions from all paid users | All paid users |
| **None** | — | Free users (upgrade CTA) |
This creates a network effect moat — every paid user who resolves a ticket makes the product better for all paid users. Solo MSP engineers (who don't have a team to build a knowledge base) get collective wisdom from day one.
### Sharing Permissions
- Any engineer can share their own resolutions to Community (opt-in per resolution)
- Team admins can **retract** any community solution shared by their team
- Team admins can **edit** any community solution from their team (in case something slips through AI sanitization)
- Team admins can toggle community sharing on/off at the **account level** (enabled by default)
- When sharing is disabled, the "Share to Community" button disappears for all team members; existing community solutions from that team remain live but no new ones can be added
### AI Sanitization Pipeline
When an engineer clicks "Share to Community," the resolution goes through an AI sanitization pass before publishing.
**What gets stripped/replaced:**
| Data type | Example | Becomes |
| --------- | ------- | ------- |
| Company names | "Contoso Corp" | `[Company]` |
| Server/host names | "DC01.contoso.local" | `[Domain Controller]` |
| IP addresses | "192.168.1.50" | `[Internal IP]` |
| Usernames | "jsmith@contoso.com" | `[User]` |
| Passwords/secrets | "P@ssw0rd123" | `[REDACTED]` |
| Ticket IDs | "TKT-2847" | `[Ticket]` |
| Client names | "Acme Medical Group" | `[Client]` |
| File paths with org info | `\\contoso-fs01\shares` | `[File Server]\shares` |
**Sanitization pipeline:**
1. **LLM pass** — sanitization prompt: "Replace all identifying information with descriptive placeholders while preserving technical accuracy"
2. **Regex pass** — safety net to catch IP patterns, email formats, UNC paths the LLM might miss
3. **Preview** — show the engineer the sanitized version before publishing; they can edit or cancel
4. **Low-confidence highlights** — if the LLM can't tell whether something is a product name or company name (e.g., "Apollo"), it highlights those sections in yellow for the engineer to confirm
The engineer always approves the sanitized preview before anything goes live.
### AI Confidence Assessment
When a sanitized solution is submitted, AI runs a quality assessment before publishing.
**AI Confidence Score (0-100):**
| Signal | Score impact |
| ------ | ----------- |
| Session ended in "resolved" status | Required (gate — only resolved sessions can be shared) |
| Clear root cause identified | +25 |
| Specific, actionable resolution steps | +20 |
| Problem is generalizable (not hyper-specific to one environment) | +15 |
| Resolution steps are reproducible by another engineer | +15 |
| Session had multiple troubleshooting steps (not a one-liner) | +10 |
| Similar solution already exists in community | -15 |
| Resolution is vague ("restarted and it worked") | -20 |
**Publishing thresholds (admin-configurable):**
- **Score 70+** — Auto-published, visible immediately
- **Score 40-69** — Published with "Unverified" badge, needs community votes to graduate
- **Score < 40** — Rejected with feedback ("This resolution is too vague to be useful. Try adding the specific steps you took.")
### Community Voting
- Any paid user can upvote or downvote a community solution
- Upvote = "This helped me" / Downvote = "This didn't work for me"
- **5 net upvotes** → "Unverified" badge removed, becomes "Community Verified"
- **3 net downvotes** → Solution hidden, flagged for staff review
- Users can leave a comment with their vote — comments go to a ResolutionFlow staff moderation queue
**Ongoing confidence adjustment:**
| Event | Score change |
| ----- | ----------- |
| Community upvote | +5 |
| Community downvote | -8 |
| Used successfully via RAG (community) | +10 |
| Rejected via RAG (community) | -3 |
| Staff review confirms quality | +20 |
| Not used in 120 days | -5 (decay) |
### Admin Controls (Super Admin Panel)
A new "Community" tab in the `/admin` panel with configurable thresholds:
| Setting | Default | Description |
| ------- | ------- | ----------- |
| Auto-publish threshold | 70 | Min AI confidence score to publish immediately |
| Review threshold | 40 | Min score to publish with "Unverified" badge |
| Upvotes to verify | 5 | Net upvotes needed to remove "Unverified" |
| Downvotes to hide | 3 | Net downvotes to auto-hide for review |
| Confidence decay days | 120 | Days of inactivity before score decays |
| Confidence decay amount | 5 | Points lost per decay cycle |
| Community sharing enabled | true | Global kill switch for all community features |
These live in the existing `platform_settings` table (JSONB). The admin panel also shows the **moderation queue** — flagged solutions and user comments requiring staff review.
### RAG Priority (Community Extension)
During live FlowPilot sessions, retrieval works in two passes:
1. **Team pass** — search team's private Solutions Library (existing behavior)
2. **Community pass** — search community solutions (only if team has `community_sharing_enabled` and user is on a paid plan)
**How FlowPilot presents community solutions differently:**
- Team solutions: *"Your colleague Sarah resolved a similar Exchange issue 3 days ago..."*
- Community solutions: *"A community solution with 12 upvotes matches your issue — an engineer found that a transport rule was blocking external senders. Want me to walk you through it?"*
**Community RAG rules:**
- Only surface community solutions with `community_status = 'published'` (not `'unverified'` or `'hidden'`)
- Require AI confidence score 60+ for RAG surfacing
- Team solutions always rank above community solutions at equal similarity
- Still max 1 suggestion per session (team OR community, whichever scores higher)
- If team solution exists at similarity > 0.7, don't bother checking community (team context is better)
### Solutions Library UI (Community Tab)
Add a tab switcher at the top of the existing Solutions Library page:
| Tab | What it shows |
| --- | ------------- |
| **My Team** | Private team solutions (current behavior) |
| **Community** | All published community solutions, searchable/filterable |
Community tab adds:
- Upvote/downvote buttons on each card
- "Community Verified" or "Unverified" badge
- "Used by X engineers" count instead of team member name
- Vote comment modal (optional, sends to staff queue)
- Filter by: confidence, votes, category, recency
### Pricing Gate
| Feature | Free | Paid |
| ------- | ---- | ---- |
| Private Solutions Library (team) | Limited (10 solutions) | Unlimited |
| Save from FlowPilot sessions | Yes | Yes |
| Community browsing | Upgrade CTA | Full access |
| Community RAG in sessions | No | Yes |
| Share to Community | No | Yes |
| Vote on community solutions | No | Yes |
| Reputation badges | No | Yes |
Free users see the "Community" tab but with a blurred preview + upgrade CTA: *"Join 500+ MSP engineers sharing solutions. Upgrade to access the community knowledge base."*
### Reputation & Incentives
**Visible reputation:**
- Profile badge showing contribution tier: **Contributor** (1+ shared) → **Expert** (10+) → **Authority** (50+)
- "Your solutions helped X engineers this month" notification (monthly digest)
- Contributor count visible on user avatar tooltip in the app
**Psychological nudges (v1):**
- **Social proof on share prompt:** After resolving a session, the save prompt says: *"47 engineers solved similar Exchange issues this week using community solutions. Share yours?"*
- **First-share celebration:** Confetti moment + "Welcome to the community!" toast
- **Streak tracking:** "You've shared 3 solutions this week" (consistency bias)
- **Loss aversion (gentle):** "You resolved 8 tickets this month but only shared 2 — 6 solutions that could help others"
**Future incentives (post-v1):**
- Leaderboard on the Community page: top contributors this month (opt-in visibility)
- Early access to new features for top contributors
- "Featured Contributor" badge on profile
- Potential: discount on subscription for consistent contributors
### Community Data Model Extensions
**New columns on `solutions` table:**
| Column | Type | Notes |
| ------ | ---- | ----- |
| visibility | VARCHAR(20) | `'private'`, `'community'` — default `'private'` |
| community_status | VARCHAR(20) | `'published'`, `'unverified'`, `'hidden'`, `'rejected'` — null for private |
| ai_confidence_score | INTEGER | 0-100, from sanitization/quality assessment |
| sanitized_content | JSONB | Anonymized version (title, problem, root_cause, steps) |
| source_solution_id | UUID | FK to solutions — links community copy to private original |
| upvote_count | INTEGER | default 0 |
| downvote_count | INTEGER | default 0 |
| shared_by_team_id | UUID | FK to accounts — which team shared it (for retraction) |
| shared_at | TIMESTAMPTZ | When it was published to community |
**New table: `solution_votes`**
| Column | Type | Notes |
| ------ | ---- | ----- |
| id | UUID | PK |
| solution_id | UUID | FK to solutions (community ones) |
| user_id | UUID | FK to users |
| vote | SMALLINT | +1 or -1 |
| comment | TEXT | Optional — goes to staff review queue |
| created_at | TIMESTAMPTZ | |
| **UNIQUE** | (solution_id, user_id) | One vote per user per solution |
**New table: `community_reports`**
| Column | Type | Notes |
| ------ | ---- | ----- |
| id | UUID | PK |
| solution_id | UUID | FK to solutions |
| reporter_id | UUID | FK to users |
| comment | TEXT | From the vote comment |
| status | VARCHAR(20) | `'pending'`, `'reviewed'`, `'actioned'` |
| reviewed_by | UUID | FK to users (staff) — nullable |
| created_at | TIMESTAMPTZ | |
| reviewed_at | TIMESTAMPTZ | |
**New columns on `accounts` (team settings):**
| Column | Type | Notes |
| ------ | ---- | ----- |
| community_sharing_enabled | BOOLEAN | default `true` — team admin toggle |
**New columns on `users` (reputation):**
| Column | Type | Notes |
| ------ | ---- | ----- |
| community_shares_count | INTEGER | default 0 — denormalized for fast badge lookup |
| community_helped_count | INTEGER | default 0 — how many times their solutions were used |
**Platform settings** (admin-tunable, in existing JSONB):
```json
{
"community_auto_publish_threshold": 70,
"community_review_threshold": 40,
"community_upvotes_to_verify": 5,
"community_downvotes_to_hide": 3,
"community_decay_days": 120,
"community_decay_amount": 5,
"community_enabled": true
}
```
### Community Implementation Phases
Extends the existing implementation phases:
#### Phase 5: Community Sharing
- `visibility` and `community_status` columns on solutions
- AI sanitization pipeline (LLM + regex + preview)
- "Share to Community" button on solution cards
- Team admin toggle for community sharing
- Team admin retract/edit for community solutions
#### Phase 6: Community Quality & Voting
- AI confidence assessment on community submissions
- `solution_votes` table + upvote/downvote UI
- `community_reports` table + staff moderation queue
- Admin-configurable thresholds in platform settings
- "Community" tab in admin panel
#### Phase 7: Community RAG
- Two-pass RAG (team-first, community-second)
- Community solution presentation in FlowPilot
- Paid-plan gating for community features
- Blurred preview + upgrade CTA for free users
#### Phase 8: Reputation & Growth
- Contribution tier badges (Contributor → Expert → Authority)
- Social proof nudges on share prompt
- First-share celebration
- "Helped X engineers" monthly digest
- Streak tracking
---
## Related: FlowPilot Engagement Nudges (Future Design)
Separate from community sharing — how do we nudge engineers to resolve issues through FlowPilot in the first place? This is the top of the funnel. Community sharing nudges are pointless if engineers aren't using FlowPilot to resolve.
**To design:**
- What triggers bring engineers back to FlowPilot?
- How do we make the resolve action feel rewarding (dopamine loop)?
- Post-resolution moments: celebration, streak tracking, team leaderboard
- Push notifications / email digests for stale sessions
- "Your team resolved 12 tickets today" social proof
Design this as a separate spec — it deserves focused thinking.

View File

@@ -13,6 +13,8 @@ import type {
AISessionSearchResult,
SimilarSession,
PickupSessionRequest,
StatusUpdateRequest,
StatusUpdateResponse,
} from '@/types/ai-session'
export const aiSessionsApi = {
@@ -136,6 +138,14 @@ export const aiSessionsApi = {
})
return response.data
},
async generateStatusUpdate(sessionId: string, data: StatusUpdateRequest): Promise<StatusUpdateResponse> {
const response = await apiClient.post<StatusUpdateResponse>(
`/ai-sessions/${sessionId}/status-update`,
data
)
return response.data
},
}
export default aiSessionsApi

View File

@@ -1,7 +1,16 @@
import { useState } from 'react'
import { CheckCircle2, ArrowUpRight, Pause, X } from 'lucide-react'
import { CheckCircle2, ArrowUpRight, Pause, X, FileText } from 'lucide-react'
import { EscalateModal } from './EscalateModal'
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
import { StatusUpdateModal } from './StatusUpdateModal'
import type {
ResolveSessionRequest,
EscalateSessionRequest,
SessionDocumentation,
StatusUpdateAudience,
StatusUpdateLength,
StatusUpdateContext,
StatusUpdateResponse,
} from '@/types/ai-session'
interface FlowPilotActionBarProps {
canResolve: boolean
@@ -9,10 +18,12 @@ interface FlowPilotActionBarProps {
isProcessing: boolean
hasPsaTicket?: boolean
sessionId?: string
canShareUpdate?: boolean
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
onAbandon?: () => Promise<void>
onGenerateStatusUpdate?: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
}
export function FlowPilotActionBar({
@@ -21,14 +32,17 @@ export function FlowPilotActionBar({
isProcessing,
hasPsaTicket = false,
sessionId,
canShareUpdate = false,
onResolve,
onEscalate,
onPause,
onAbandon,
onGenerateStatusUpdate,
}: FlowPilotActionBarProps) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [showAbandon, setShowAbandon] = useState(false)
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -90,6 +104,17 @@ export function FlowPilotActionBar({
<ArrowUpRight size={16} />
Escalate
</button>
{canShareUpdate && onGenerateStatusUpdate && (
<button
onClick={() => setShowStatusUpdate(true)}
disabled={isProcessing}
className="flex flex-1 sm:flex-initial items-center justify-center gap-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 px-4 py-2 min-h-[44px] text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<FileText size={16} />
<span className="hidden sm:inline">Share Update</span>
<span className="sm:hidden">Update</span>
</button>
)}
</div>
<div className="flex gap-2 sm:ml-auto">
{onPause && (
@@ -184,6 +209,17 @@ export function FlowPilotActionBar({
hasPsaTicket={hasPsaTicket}
sessionId={sessionId}
/>
{/* Status Update modal */}
{onGenerateStatusUpdate && (
<StatusUpdateModal
open={showStatusUpdate}
onClose={() => setShowStatusUpdate(false)}
onGenerate={onGenerateStatusUpdate}
context="status"
hasPsaTicket={hasPsaTicket}
/>
)}
</>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Network, Clock, Hash, Play, Ticket, ChevronDown, ChevronUp } from 'lucide-react'
import { Network, Clock, Hash, Play, Ticket, ChevronDown, ChevronUp, FileText } from 'lucide-react'
import type {
AISessionDetail,
AISessionStepResponse,
@@ -7,12 +7,17 @@ import type {
ResolveSessionRequest,
EscalateSessionRequest,
SessionDocumentation,
StatusUpdateAudience,
StatusUpdateLength,
StatusUpdateContext,
StatusUpdateResponse,
} from '@/types/ai-session'
import { ConfidenceIndicator } from './ConfidenceIndicator'
import { FlowPilotStepCard } from './FlowPilotStepCard'
import { FlowPilotActionBar } from './FlowPilotActionBar'
import { FlowPilotMessageBar } from './FlowPilotMessageBar'
import { SessionDocView } from './SessionDocView'
import { StatusUpdateModal } from './StatusUpdateModal'
import { SessionTicketCard } from './SessionTicketCard'
import { SimilarSessions } from './SimilarSessions'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
@@ -39,6 +44,7 @@ interface FlowPilotSessionProps {
onAbandon?: () => Promise<void>
onRate: (rating: number) => void
onReloadSession?: () => Promise<void>
onGenerateStatusUpdate?: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
}
export function FlowPilotSession({
@@ -60,10 +66,12 @@ export function FlowPilotSession({
onAbandon,
onRate,
onReloadSession,
onGenerateStatusUpdate,
}: FlowPilotSessionProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const [showTicketPicker, setShowTicketPicker] = useState(false)
const [linkingTicket, setLinkingTicket] = useState(false)
const [showShareCommunication, setShowShareCommunication] = useState(false)
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
const handleLinkTicket = async (ticketId: string, _ticket: PSATicketInfo) => {
@@ -115,9 +123,24 @@ export function FlowPilotSession({
// Show documentation view for completed sessions
if (isCompleted && documentation) {
const shareContext = session.status === 'resolved' ? 'resolution' as const : 'escalation' as const
const shareLabel = session.status === 'resolved' ? 'Share Resolution' : 'Share Escalation'
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto p-3 sm:p-4 lg:p-6">
{/* Share Resolution/Escalation button */}
{onGenerateStatusUpdate && (
<div className="mb-4">
<button
onClick={() => setShowShareCommunication(true)}
className="flex items-center gap-2 rounded-lg bg-cyan-500/10 border border-cyan-500/20 px-4 py-2.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<FileText size={16} />
{shareLabel}
</button>
</div>
)}
<SessionDocView
documentation={documentation}
onRate={onRate}
@@ -129,6 +152,17 @@ export function FlowPilotSession({
ticketId={session.psa_ticket_id}
/>
</div>
{/* Share communication modal for resolved/escalated sessions */}
{onGenerateStatusUpdate && (
<StatusUpdateModal
open={showShareCommunication}
onClose={() => setShowShareCommunication(false)}
onGenerate={onGenerateStatusUpdate}
context={shareContext}
hasPsaTicket={!!session.psa_ticket_id}
/>
)}
</div>
)
}
@@ -320,10 +354,12 @@ export function FlowPilotSession({
isProcessing={isProcessing}
hasPsaTicket={!!session.psa_ticket_id}
sessionId={session.id}
canShareUpdate={allSteps.length >= 2}
onResolve={onResolve}
onEscalate={onEscalate}
onPause={onPause}
onAbandon={onAbandon}
onGenerateStatusUpdate={onGenerateStatusUpdate}
/>
)}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
interface StatusUpdateModalProps {
open: boolean
onClose: () => void
onGenerate: (audience: StatusUpdateAudience, length: StatusUpdateLength, context: StatusUpdateContext) => Promise<StatusUpdateResponse>
context?: StatusUpdateContext
hasPsaTicket?: boolean
}
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
]
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
{ value: 'quick', icon: Zap, label: 'Quick', description: '1-2 sentences' },
{ value: 'detailed', icon: AlignLeft, label: 'Detailed', description: 'Full breakdown' },
]
type ModalStep = 'audience' | 'length' | 'generating' | 'result'
export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status', hasPsaTicket = false }: StatusUpdateModalProps) {
const [step, setStep] = useState<ModalStep>('audience')
const [audience, setAudience] = useState<StatusUpdateAudience | null>(null)
const [length, setLength] = useState<StatusUpdateLength | null>(null)
const [result, setResult] = useState<StatusUpdateResponse | null>(null)
const [copied, setCopied] = useState(false)
const contextLabels: Record<StatusUpdateContext, string> = {
status: 'Share Status Update',
resolution: 'Share Resolution',
escalation: 'Share Escalation',
}
const handleAudienceSelect = (value: StatusUpdateAudience) => {
setAudience(value)
setStep('length')
}
const handleLengthSelect = async (value: StatusUpdateLength) => {
if (!audience) return
setLength(value)
setStep('generating')
try {
const res = await onGenerate(audience, value, context)
setResult(res)
setStep('result')
} catch {
setStep('audience')
setAudience(null)
setLength(null)
}
}
const handleCopy = async () => {
if (!result) return
await navigator.clipboard.writeText(result.content)
setCopied(true)
toast.success('Copied to clipboard')
setTimeout(() => setCopied(false), 2000)
}
const handleRegenerate = async () => {
if (!audience || !length) return
setStep('generating')
try {
const res = await onGenerate(audience, length, context)
setResult(res)
setStep('result')
} catch {
setStep('result')
}
}
const handleSwitchAudience = () => {
setStep('audience')
setAudience(null)
setLength(null)
setResult(null)
setCopied(false)
}
const handleClose = () => {
setStep('audience')
setAudience(null)
setLength(null)
setResult(null)
setCopied(false)
onClose()
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="card-flat w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 rounded-t-2xl sm:rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 pt-4 sm:pt-6 pb-3" style={{ borderBottom: '1px solid var(--color-border-default)' }}>
<h3 className="font-heading text-lg font-semibold text-foreground">
{contextLabels[context]}
</h3>
<button onClick={handleClose} className="text-muted-foreground hover:text-foreground text-sm">
Cancel
</button>
</div>
<div className="px-4 sm:px-6 py-4 sm:py-5">
{/* Step 1: Audience */}
{step === 'audience' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">Who is this update for?</p>
{AUDIENCES.map((opt) => {
const Icon = opt.icon
return (
<button
key={opt.value}
onClick={() => handleAudienceSelect(opt.value)}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
style={{ border: '1px solid var(--color-border-default)' }}
>
<Icon size={18} className="text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
</button>
)
})}
</div>
)}
{/* Step 2: Length */}
{step === 'length' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">How detailed?</p>
{LENGTHS.map((opt) => {
const Icon = opt.icon
return (
<button
key={opt.value}
onClick={() => handleLengthSelect(opt.value)}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
style={{ border: '1px solid var(--color-border-default)' }}
>
<Icon size={18} className="text-muted-foreground shrink-0" />
<div>
<p className="text-sm font-medium text-foreground">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
</button>
)
})}
<button
onClick={() => { setStep('audience'); setAudience(null) }}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
Back
</button>
</div>
)}
{/* Step 3: Generating */}
{step === 'generating' && (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 size={24} className="animate-spin text-cyan-400" />
<p className="text-sm text-muted-foreground">
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
</p>
</div>
)}
{/* Step 4: Result */}
{step === 'result' && result && (
<div className="space-y-4">
{/* Meta badges */}
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{AUDIENCES.find(a => a.value === result.audience)?.label}
</span>
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-[rgba(255,255,255,0.06)] text-muted-foreground border border-[rgba(255,255,255,0.08)]">
{result.length === 'quick' ? 'Quick' : 'Detailed'}
</span>
{result.time_spent_display && (
<span className="text-xs text-muted-foreground">
{result.steps_completed} steps · {result.time_spent_display}
</span>
)}
</div>
{/* Generated content */}
<div
className="max-h-64 overflow-y-auto rounded-lg p-4 text-sm text-foreground whitespace-pre-wrap font-mono leading-relaxed"
style={{ background: 'var(--color-bg-page)', border: '1px solid var(--color-border-default)' }}
>
{result.content}
</div>
{/* Action buttons */}
<div className="flex flex-wrap gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-lg px-4 py-2 min-h-[44px] text-sm font-medium transition-colors',
copied
? 'bg-emerald-500/20 border border-emerald-500/30 text-emerald-400'
: 'bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
)}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={handleRegenerate}
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 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
>
<RotateCcw size={16} />
Regenerate
</button>
<button
onClick={handleSwitchAudience}
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 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
>
<ArrowLeftRight size={16} />
Switch
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -13,3 +13,4 @@ export { SessionBriefing } from './SessionBriefing'
export { ProposalCard } from './ProposalCard'
export { ProposalDetail } from './ProposalDetail'
export { InSessionScriptGenerator } from './InSessionScriptGenerator'
export { StatusUpdateModal } from './StatusUpdateModal'

View File

@@ -10,6 +10,8 @@ import type {
ResolveSessionRequest,
EscalateSessionRequest,
SessionDocumentation,
StatusUpdateRequest,
StatusUpdateResponse,
} from '@/types/ai-session'
import { toast } from '@/lib/toast'
@@ -31,6 +33,7 @@ export interface UseFlowPilotSession {
resumeOwnSession: () => Promise<void>
abandonSession: () => Promise<void>
rateSession: (rating: number, feedback?: string) => Promise<void>
generateStatusUpdate: (data: StatusUpdateRequest) => Promise<StatusUpdateResponse>
loadSession: (sessionId: string) => Promise<void>
// Derived
@@ -214,6 +217,17 @@ export function useFlowPilotSession(): UseFlowPilotSession {
}
}, [session])
const generateStatusUpdate = useCallback(async (data: StatusUpdateRequest): Promise<StatusUpdateResponse> => {
if (!session) throw new Error('No active session')
try {
return await aiSessionsApi.generateStatusUpdate(session.id, data)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to generate status update'
toast.error(message)
throw e
}
}, [session])
const loadSession = useCallback(async (sessionId: string) => {
setIsLoading(true)
setError(null)
@@ -260,6 +274,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
resumeOwnSession,
abandonSession,
rateSession,
generateStatusUpdate,
loadSession,
isActive,
canResolve,

View File

@@ -237,6 +237,7 @@ export default function FlowPilotSessionPage() {
}}
onRate={fp.rateSession}
onReloadSession={() => fp.loadSession(fp.session!.id)}
onGenerateStatusUpdate={fp.generateStatusUpdate}
/>
</div>
</div>

View File

@@ -128,6 +128,28 @@ export interface SessionCloseResponse {
member_mapping_warning: string | null
}
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft'
export type StatusUpdateLength = 'quick' | 'detailed'
export type StatusUpdateContext = 'status' | 'resolution' | 'escalation'
export interface StatusUpdateRequest {
audience: StatusUpdateAudience
length: StatusUpdateLength
context: StatusUpdateContext
}
export interface StatusUpdateResponse {
content: string
audience: StatusUpdateAudience
length: StatusUpdateLength
context: StatusUpdateContext
session_status: string
steps_completed: number
time_spent_display: string | null
client_name: string | null
generated_at: string
}
export interface RateSessionRequest {
rating: number
feedback?: string | null