feat: AI chat conclusion + survey completion & management #95

Merged
chihlasm merged 5 commits from feat/chat-conclusion-survey-management into main 2026-03-06 03:43:02 +00:00
20 changed files with 1627 additions and 63 deletions
Showing only changes of commit 882f67f42e - Show all commits

View File

@@ -0,0 +1,36 @@
"""Add conclusion fields to assistant_chats.
Revision ID: 048
Revises: 047
Create Date: 2026-03-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "048"
down_revision: str = "047"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"assistant_chats",
sa.Column("conclusion_outcome", sa.String(20), nullable=True),
)
op.add_column(
"assistant_chats",
sa.Column("conclusion_summary", sa.String, nullable=True),
)
op.add_column(
"assistant_chats",
sa.Column("concluded_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("assistant_chats", "concluded_at")
op.drop_column("assistant_chats", "conclusion_summary")
op.drop_column("assistant_chats", "conclusion_outcome")

View File

@@ -0,0 +1,31 @@
"""Add is_read and archived_at to survey_responses.
Revision ID: 049
Revises: 048
Create Date: 2026-03-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "049"
down_revision: str = "048"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"survey_responses",
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="false"),
)
op.add_column(
"survey_responses",
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("survey_responses", "archived_at")
op.drop_column("survey_responses", "is_read")

View File

@@ -4,10 +4,11 @@ import io
import logging
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
@@ -96,6 +97,7 @@ async def list_survey_invites(
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
include_archived: bool = False,
):
"""List all survey responses with summary stats."""
stmt = (
@@ -103,11 +105,15 @@ async def list_survey_responses(
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
if not include_archived:
stmt = stmt.where(SurveyResponse.archived_at.is_(None))
result = await db.execute(stmt)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
this_week = 0
unread = 0
responses: list[SurveyResponseDetail] = []
for survey_resp, invite_name in rows:
@@ -119,19 +125,168 @@ async def list_survey_responses(
responses=survey_resp.responses,
source=source,
invite_name=invite_name,
is_read=survey_resp.is_read,
archived_at=survey_resp.archived_at,
created_at=survey_resp.created_at,
)
)
if survey_resp.created_at >= one_week_ago:
this_week += 1
if not survey_resp.is_read:
unread += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
unread=unread,
)
@router.put("/survey-responses/{response_id}/read", status_code=200)
async def mark_response_read(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Mark a survey response as read."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.is_read = True
await db.commit()
return {"id": str(resp.id), "is_read": True}
@router.put("/survey-responses/{response_id}/unread", status_code=200)
async def mark_response_unread(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Mark a survey response as unread."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.is_read = False
await db.commit()
return {"id": str(resp.id), "is_read": False}
@router.put("/survey-responses/{response_id}/archive", status_code=200)
async def archive_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.archived_at = datetime.now(timezone.utc)
await db.commit()
return {"id": str(resp.id), "archived_at": resp.archived_at.isoformat()}
@router.put("/survey-responses/{response_id}/unarchive", status_code=200)
async def unarchive_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Unarchive a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
resp.archived_at = None
await db.commit()
return {"id": str(resp.id), "archived_at": None}
@router.delete("/survey-responses/{response_id}", status_code=204)
async def delete_response(
response_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a survey response."""
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == response_id)
)
resp = result.scalar_one_or_none()
if not resp:
raise HTTPException(status_code=404, detail="Response not found")
await db.delete(resp)
await db.commit()
@router.post("/survey-responses/bulk", status_code=200)
async def bulk_action_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
action: str = Body(...),
ids: list[str] = Body(...),
):
"""Bulk action on survey responses. Actions: mark_read, mark_unread, archive, delete."""
from uuid import UUID as _UUID
uuids = []
for id_str in ids:
try:
uuids.append(_UUID(id_str))
except ValueError:
continue
if not uuids:
raise HTTPException(status_code=400, detail="No valid IDs provided")
if action == "mark_read":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(is_read=True)
)
elif action == "mark_unread":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(is_read=False)
)
elif action == "archive":
await db.execute(
update(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
.values(archived_at=datetime.now(timezone.utc))
)
elif action == "delete":
await db.execute(
delete(SurveyResponse)
.where(SurveyResponse.id.in_(uuids))
)
else:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
await db.commit()
return {"action": action, "count": len(uuids)}
# Question IDs in survey order, used for CSV export columns.
QUESTION_IDS = [
"prereqs",

View File

@@ -35,6 +35,8 @@ from app.schemas.assistant_chat import (
ChatUpdateRequest,
RetentionSettingsResponse,
RetentionSettingsUpdate,
ConcludeChatRequest,
ConcludeChatResponse,
)
from app.schemas.copilot import SuggestedFlow
from app.services import assistant_chat_service
@@ -203,6 +205,67 @@ async def post_message(
)
@router.post("/chats/{chat_id}/conclude", response_model=ConcludeChatResponse)
@limiter.limit("10/minute")
async def conclude_chat(
request: Request,
chat_id: UUID,
data: ConcludeChatRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Conclude a chat session and generate ticket-ready summary."""
_require_ai_enabled()
result = await db.execute(
select(AssistantChat).where(
AssistantChat.id == chat_id,
AssistantChat.user_id == current_user.id,
)
)
chat = result.scalar_one_or_none()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
if chat.concluded_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Chat already concluded",
)
if chat.message_count < 2:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Chat must have at least one exchange before concluding",
)
try:
summary = await assistant_chat_service.generate_conclusion_summary(
chat=chat,
outcome=data.outcome,
notes=data.notes,
)
except Exception as e:
logger.exception("Failed to generate conclusion summary: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to generate summary. Please try again.",
)
now = datetime.now(timezone.utc)
chat.conclusion_outcome = data.outcome
chat.conclusion_summary = summary
chat.concluded_at = now
await db.commit()
return ConcludeChatResponse(
summary=summary,
outcome=data.outcome,
concluded_at=now,
)
@router.patch("/chats/{chat_id}", response_model=ChatDetailResponse)
async def update_chat(
chat_id: UUID,

View File

@@ -14,7 +14,7 @@ from app.core.email import EmailService
from app.core.rate_limit import limiter
from app.models.survey_invite import SurveyInvite
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
from app.schemas.survey import SurveyEmailCopyRequest, SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
logger = logging.getLogger(__name__)
@@ -88,3 +88,79 @@ async def submit_survey(
await db.commit()
return SurveySubmissionResponse(id=str(response.id))
# Question metadata for formatting email copy
_QUESTION_LABELS = {
"prereqs": "Q1. Before you start troubleshooting, what info do you need?",
"verify_fix": "Q2. After you apply a fix, how do you verify it actually worked?",
"steps_at_a_time": "Q3. How many steps do you prefer to see at once?",
"first_step": "Q4. \"Internet is down.\" What's your FIRST move?",
"junior_mistake": "Q5. Most common mistake junior engineers make?",
"pivot": "Q6. When do you stop pursuing one theory and pivot?",
"scenario_approach": "Q7. First 3 diagnostic steps for this ticket.",
"scenario_deeper": "Q8. Server pings fine, you can RDP in. What next?",
"doc_pct": "Q9. Percentage of steps you actually document?",
"go_to_commands": "Q10. Top 3 go-to PowerShell commands?",
"secret_weapon": "Q11. Secret weapon command/tool/technique?",
"gotcha": "Q12. Issue where the obvious diagnosis was WRONG?",
"hard_rules": "Q13. Which rules do you follow?",
"prioritization": "Q14. Rank factors by diagnostic priority.",
"detail_level": "Q15. How specific should AI suggestions be?",
"ai_personality": "Q16. What makes an AI feel like a useful colleague?",
}
_QUESTION_ORDER = [
"prereqs", "verify_fix", "steps_at_a_time", "first_step", "junior_mistake",
"pivot", "scenario_approach", "scenario_deeper", "doc_pct", "go_to_commands",
"secret_weapon", "gotcha", "hard_rules", "prioritization", "detail_level", "ai_personality",
]
@router.post("/survey/email-copy")
@limiter.limit("5/hour")
async def email_survey_copy(
request: Request,
data: SurveyEmailCopyRequest,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Email a copy of survey responses to the respondent."""
from uuid import UUID as _UUID
try:
resp_id = _UUID(data.response_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid response ID")
result = await db.execute(
select(SurveyResponse).where(SurveyResponse.id == resp_id)
)
response = result.scalar_one_or_none()
if not response:
raise HTTPException(status_code=404, detail="Response not found")
# Build formatted responses for the email
answers = response.responses or {}
formatted_lines = []
for qid in _QUESTION_ORDER:
label = _QUESTION_LABELS.get(qid, qid)
val = answers.get(qid)
if isinstance(val, list):
answer_str = ", ".join(str(v) for v in val)
elif val is not None:
answer_str = str(val)
else:
answer_str = "(no answer)"
formatted_lines.append(f"{label}\n{answer_str}")
try:
await EmailService.send_survey_copy_email(
to_email=data.email,
respondent_name=response.respondent_name,
formatted_responses="\n\n".join(formatted_lines),
)
except Exception:
logger.exception("Failed to send survey copy email to %s", data.email)
raise HTTPException(status_code=502, detail="Failed to send email. Please try again.")
return {"message": "Email sent"}

View File

@@ -354,6 +354,70 @@ class EmailService:
logger.exception("Failed to send survey notification email")
return False
@staticmethod
async def send_survey_copy_email(
to_email: str,
respondent_name: str | None,
formatted_responses: str,
) -> bool:
"""Send a copy of survey responses to the respondent."""
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
import html as html_mod
resend.api_key = settings.RESEND_API_KEY
safe_name = html_mod.escape(respondent_name or "there")
safe_responses = html_mod.escape(formatted_responses).replace("\n", "<br>")
subject = "Your FlowPilot Survey Responses"
email_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">Your Survey Responses</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:24px;">
<p style="margin:0;color:#e0e0e0;font-size:13px;line-height:1.8;">{safe_responses}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
Thank you for your contribution to FlowPilot research.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
resend.Emails.send({
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": email_html,
})
logger.info("Survey copy email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send survey copy email to %s", to_email)
return False
@staticmethod
async def send_survey_invite_email(
to_email: str,

View File

@@ -49,6 +49,15 @@ class AssistantChat(Base):
pinned: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
conclusion_outcome: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True
)
conclusion_summary: Mapped[Optional[str]] = mapped_column(
String, nullable=True
)
concluded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -2,7 +2,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from app.core.database import Base
@@ -17,4 +17,6 @@ class SurveyResponse(Base):
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True)
is_read = Column(Boolean, nullable=False, default=False)
archived_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)

View File

@@ -1,5 +1,5 @@
"""Pydantic schemas for standalone AI assistant chat."""
from typing import Optional, Any
from typing import Optional, Any, Literal
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, Field
@@ -57,3 +57,14 @@ class RetentionSettingsResponse(BaseModel):
class RetentionSettingsUpdate(BaseModel):
chat_retention_days: Optional[int] = Field(None, ge=1, le=365)
chat_retention_max_count: Optional[int] = Field(None, ge=10, le=10000)
class ConcludeChatRequest(BaseModel):
outcome: Literal["resolved", "escalated", "paused"]
notes: Optional[str] = Field(None, max_length=2000)
class ConcludeChatResponse(BaseModel):
summary: str
outcome: str
concluded_at: datetime

View File

@@ -46,6 +46,12 @@ class SurveyInviteStatus(BaseModel):
status: str
class SurveyEmailCopyRequest(BaseModel):
"""Request to email a copy of responses to the respondent."""
email: str = Field(..., max_length=255)
response_id: str
class SurveyResponseDetail(BaseModel):
"""Full survey response returned to admin."""
id: str
@@ -53,6 +59,8 @@ class SurveyResponseDetail(BaseModel):
responses: dict[str, Any]
source: str
invite_name: Optional[str]
is_read: bool = False
archived_at: Optional[datetime] = None
created_at: datetime
@@ -61,3 +69,4 @@ class SurveyResponseListResponse(BaseModel):
responses: list[SurveyResponseDetail]
total: int
this_week: int
unread: int

View File

@@ -228,6 +228,117 @@ def _auto_title(message: str) -> str:
return title
CONCLUSION_SYSTEM_PROMPT = """\
You are a ticket documentation specialist for MSP (Managed Service Provider) teams. \
Your job is to transform an AI troubleshooting conversation into clean, professional \
ticket notes that can be pasted directly into a PSA/ticketing system (ConnectWise, \
Autotask, HaloPSA, etc.).
## Output Format
Generate a structured summary using this exact format:
**Subject:** [One-line summary of the issue]
**Outcome:** {outcome_label}
**Problem Description:**
[2-3 sentence summary of the original problem]
**Steps Taken:**
1. [Step] — [Result/finding]
2. [Step] — [Result/finding]
(list all troubleshooting steps from the conversation)
**Current Status:**
[Where things stand now — what was resolved, what remains]
{notes_section}
**Key Findings:**
- [Important discovery or configuration detail]
- [Any relevant error codes, settings, or values identified]
{resume_section}
## Rules
- Be concise but thorough — these notes will be read by another engineer
- Include specific technical details (commands run, error messages, config values)
- Use plain text formatting (no HTML) — bold with ** is fine
- Do NOT include conversational filler, greetings, or meta-commentary
- Extract ALL actionable steps from the conversation, in chronological order
- If the conversation identified root cause, state it clearly
"""
async def generate_conclusion_summary(
chat: "AssistantChat",
outcome: str,
notes: str | None = None,
) -> str:
"""Generate a ticket-ready summary from a concluded chat conversation."""
outcome_labels = {
"resolved": "Resolved",
"escalated": "Escalated",
"paused": "Paused — To Be Continued",
}
outcome_label = outcome_labels.get(outcome, outcome)
notes_section = ""
if notes:
notes_section = f"\n**Engineer Notes:**\n{notes}\n"
resume_section = ""
if outcome == "paused":
resume_section = (
"\n**Next Steps (for resumption):**\n"
"- [What needs to happen next]\n"
"- [Any pending actions or follow-ups]\n"
)
elif outcome == "escalated":
resume_section = (
"\n**Escalation Details:**\n"
"- [Reason for escalation]\n"
"- [Recommended next steps for receiving team/tier]\n"
)
# Build the conversation transcript for the AI
transcript_lines = []
for msg in chat.messages:
role_label = "ENGINEER" if msg["role"] == "user" else "AI ASSISTANT"
transcript_lines.append(f"[{role_label}]: {msg['content']}")
transcript = "\n\n".join(transcript_lines)
prompt = (
f"Outcome: {outcome_label}\n\n"
f"{'Engineer Notes: ' + notes if notes else '(No additional notes)'}\n\n"
f"--- CONVERSATION TRANSCRIPT ---\n\n{transcript}\n\n"
f"--- END TRANSCRIPT ---\n\n"
f"Generate the ticket notes now. Replace all placeholder brackets with actual content from the conversation. "
f"The notes_section placeholder should be: {notes_section or '(omit this section)'}\n"
f"The resume_section placeholder should be filled based on the conversation context."
)
system_with_vars = CONCLUSION_SYSTEM_PROMPT.replace(
"{outcome_label}", outcome_label
).replace(
"{notes_section}", notes_section or ""
).replace(
"{resume_section}", resume_section
)
content, _, _ = await _call_ai(
system_base=system_with_vars,
rag_context="",
history=[],
new_message=prompt,
max_tokens=2048,
)
return content
async def create_chat(
user_id: UUID,
account_id: UUID,

View File

@@ -38,6 +38,8 @@ export interface SurveyResponseDetail {
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
is_read: boolean
archived_at: string | null
created_at: string
}
@@ -45,6 +47,7 @@ export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
unread: number
}
export const adminApi = {
@@ -175,10 +178,22 @@ export const adminApi = {
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
listSurveyResponses: (includeArchived = false) =>
api.get<SurveyResponseListResponse>('/admin/survey-responses', { params: { include_archived: includeArchived } }).then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
markResponseRead: (id: string) =>
api.put(`/admin/survey-responses/${id}/read`).then(r => r.data),
markResponseUnread: (id: string) =>
api.put(`/admin/survey-responses/${id}/unread`).then(r => r.data),
archiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/archive`).then(r => r.data),
unarchiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/unarchive`).then(r => r.data),
deleteResponse: (id: string) =>
api.delete(`/admin/survey-responses/${id}`),
bulkActionResponses: (action: string, ids: string[]) =>
api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data),
}
export default adminApi

View File

@@ -4,6 +4,8 @@ import type {
ChatListItem,
ChatMessageResponse,
RetentionSettings,
ConcludeChatRequest,
ConcludeChatResponse,
} from '@/types/assistant-chat'
export const assistantChatApi = {
@@ -54,6 +56,14 @@ export const assistantChatApi = {
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
return response.data
},
async concludeChat(chatId: string, data: ConcludeChatRequest): Promise<ConcludeChatResponse> {
const response = await apiClient.post<ConcludeChatResponse>(
`/assistant/chats/${chatId}/conclude`,
data
)
return response.data
},
}
export default assistantChatApi

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import {
X,
CheckCircle2,
ArrowUpRight,
Pause,
Loader2,
Copy,
Check,
RefreshCw,
ClipboardList,
Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
interface ConcludeSessionModalProps {
isOpen: boolean
onClose: () => void
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
onResumeNew: (summary: string) => void
chatTitle: string
}
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
{
value: 'resolved',
label: 'Resolved',
description: 'Issue has been fixed or answered',
icon: CheckCircle2,
color: 'text-emerald-400',
bg: 'bg-emerald-400/10',
border: 'border-emerald-400/30',
},
{
value: 'escalated',
label: 'Escalate',
description: 'Needs to be handed off or escalated',
icon: ArrowUpRight,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/30',
},
{
value: 'paused',
label: 'Paused',
description: 'Continuing later — saving progress',
icon: Pause,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/30',
},
]
type ModalStep = 'select-outcome' | 'add-notes' | 'summary'
export function ConcludeSessionModal({
isOpen,
onClose,
onConclude,
onResumeNew,
chatTitle,
}: ConcludeSessionModalProps) {
const [step, setStep] = useState<ModalStep>('select-outcome')
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
const [notes, setNotes] = useState('')
const [summary, setSummary] = useState('')
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('select-outcome')
setOutcome(null)
setNotes('')
setSummary('')
setGenerating(false)
setCopied(false)
setError(null)
}
}, [isOpen])
const handleOutcomeSelect = (selected: ConclusionOutcome) => {
setOutcome(selected)
setStep('add-notes')
}
const handleGenerate = async () => {
if (!outcome) return
setGenerating(true)
setError(null)
try {
const result = await onConclude(outcome, notes)
setSummary(result)
setStep('summary')
} catch {
setError('Failed to generate summary. Please try again.')
} finally {
setGenerating(false)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(summary)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = summary
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleResumeNew = () => {
onResumeNew(summary)
onClose()
}
if (!isOpen) return null
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
className="relative w-full max-w-2xl mx-4 glass-card-static overflow-hidden animate-in fade-in zoom-in-95 duration-200"
style={{
maxHeight: 'calc(100vh - 4rem)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<ClipboardList size={18} className="text-primary" />
</div>
<div>
<h2 className="text-base font-heading font-semibold text-foreground">
Conclude Session
</h2>
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{chatTitle}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
{/* Step indicator */}
<div
className="px-6 py-3 border-b shrink-0 flex items-center gap-2"
style={{ borderColor: 'var(--glass-border)' }}
>
{(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => (
<div key={s} className="flex items-center gap-2">
{i > 0 && (
<div
className={cn(
'w-8 h-px',
step === s || (i === 1 && step === 'summary') || (i === 2 && step === 'summary')
? 'bg-primary/40'
: 'bg-[rgba(255,255,255,0.06)]'
)}
/>
)}
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-label font-medium transition-colors',
step === s
? 'bg-gradient-brand text-[#101114]'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
? 'bg-primary/20 text-primary'
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}
>
{i + 1}
</div>
<span
className={cn(
'text-xs font-label',
step === s ? 'text-foreground' : 'text-muted-foreground'
)}
>
{s === 'select-outcome' ? 'Outcome' : s === 'add-notes' ? 'Notes' : 'Summary'}
</span>
</div>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Step 1: Select Outcome */}
{step === 'select-outcome' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">
How did this session end?
</p>
{OUTCOMES.map(o => {
const Icon = o.icon
return (
<button
key={o.value}
onClick={() => handleOutcomeSelect(o.value)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left',
'hover:scale-[1.01] active:scale-[0.99]',
'bg-[rgba(255,255,255,0.02)] border-[rgba(255,255,255,0.06)]',
'hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.04)]'
)}
>
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', o.bg)}>
<Icon size={20} className={o.color} />
</div>
<div>
<span className="text-sm font-semibold text-foreground block">{o.label}</span>
<span className="text-xs text-muted-foreground">{o.description}</span>
</div>
</button>
)
})}
</div>
)}
{/* Step 2: Add Notes */}
{step === 'add-notes' && selectedOutcome && (
<div className="space-y-4">
{/* Selected outcome badge */}
<div className="flex items-center gap-2">
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
<button
onClick={() => setStep('select-outcome')}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Change
</button>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-2">
Additional Notes (optional)
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={
outcome === 'resolved'
? 'Any additional context about the resolution...'
: outcome === 'escalated'
? 'Reason for escalation, who to assign to...'
: 'What still needs to be done, where you left off...'
}
rows={4}
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
/>
</div>
{error && (
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
{error}
</div>
)}
</div>
)}
{/* Step 3: Summary */}
{step === 'summary' && (
<div className="space-y-4">
{/* Outcome badge */}
{selectedOutcome && (
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
)}
{/* Generated summary */}
<div
className="rounded-xl border p-5 bg-[rgba(255,255,255,0.02)]"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center justify-between mb-3">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" />
Generated Ticket Notes
</span>
</div>
<div className="prose-sm text-foreground">
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
</div>
)}
</div>
{/* Footer actions */}
<div
className="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-3"
style={{ borderColor: 'var(--glass-border)' }}
>
{step === 'select-outcome' && (
<>
<div />
<button
onClick={onClose}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Cancel
</button>
</>
)}
{step === 'add-notes' && (
<>
<button
onClick={() => setStep('select-outcome')}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Back
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
>
{generating ? (
<>
<Loader2 size={15} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={15} />
Generate Summary
</>
)}
</button>
</>
)}
{step === 'summary' && (
<>
<div className="flex items-center gap-2">
{outcome === 'paused' && (
<button
onClick={handleResumeNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
>
<RefreshCw size={14} />
Resume in New Chat
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
copied
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
: 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
)}
>
{copied ? (
<>
<Check size={15} />
Copied!
</>
) : (
<>
<Copy size={15} />
Copy to Clipboard
</>
)}
</button>
<button
onClick={onClose}
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Done
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles, Send, Loader2 } from 'lucide-react'
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
import { assistantChatApi } from '@/api/assistantChat'
import { toast } from '@/lib/toast'
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
@@ -17,6 +18,7 @@ export default function AssistantChatPage() {
const [messages, setMessages] = useState<MessageWithMeta[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [showConclude, setShowConclude] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -120,6 +122,55 @@ export default function AssistantChatPage() {
}
}
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
if (!activeChatId) throw new Error('No active chat')
const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined })
// Update chat in sidebar to show concluded status
setChats(prev =>
prev.map(c =>
c.id === activeChatId
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
: c
)
)
return response.summary
}
const handleResumeNew = async (summary: string) => {
try {
const chat = await assistantChatApi.createChat()
setChats(prev => [
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
...prev,
])
setActiveChatId(chat.id)
setMessages([])
// Send the summary as the first message to prime the new chat
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
setInput('')
setMessages([{ role: 'user', content: resumePrompt }])
setLoading(true)
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
setChats(prev =>
prev.map(c =>
c.id === chat.id
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
: c
)
)
} catch {
toast.error('Failed to create resume chat')
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -194,13 +245,27 @@ export default function AssistantChatPage() {
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={18} />
</button>
<div className="flex flex-col gap-2">
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Send size={18} />
</button>
{messages.length >= 2 && (
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
>
<Flag size={18} />
</button>
)}
</div>
</div>
</div>
</>
@@ -225,6 +290,15 @@ export default function AssistantChatPage() {
</div>
)}
</div>
{/* Conclude Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}
onConclude={handleConclude}
onResumeNew={handleResumeNew}
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/common/BrandLogo'
// ── Survey Data Types ──
@@ -147,6 +147,12 @@ export default function SurveyPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [submitError, setSubmitError] = useState('')
const [emailInput, setEmailInput] = useState('')
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [emailError, setEmailError] = useState('')
const [responseId, setResponseId] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('t')
@@ -203,6 +209,8 @@ export default function SurveyPage() {
const errData = await res.json().catch(() => null)
throw new Error(errData?.detail || `Submission failed (${res.status})`)
}
const data = await res.json()
setResponseId(data.id)
setIsComplete(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (err) {
@@ -257,9 +265,20 @@ export default function SurveyPage() {
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed mb-3">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
You can safely close this browser window now.
</p>
<div className="glass-card-static p-5 max-w-[400px] mx-auto text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Have feedback unrelated to the survey?{' '}
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
@@ -385,13 +404,86 @@ export default function SurveyPage() {
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Done — Thank You!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-7 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. We truly appreciate your time and expertise.
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-8 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
</p>
{/* Email a copy */}
<div className="glass-card-static p-6 max-w-[420px] mx-auto mb-5">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
Email a copy to yourself
</p>
{!emailSent ? (
<div className="flex gap-2">
<input
type="email"
value={emailInput}
onChange={e => setEmailInput(e.target.value)}
placeholder="your@email.com"
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-[#5a6170] focus:outline-none"
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--glass-border)' }}
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)' }}
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)' }}
disabled={emailSending}
/>
<button
onClick={async () => {
if (!emailInput.trim() || !responseId) return
setEmailSending(true)
setEmailError('')
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const res = await fetch(`${apiUrl}/api/v1/survey/email-copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.trim(), response_id: responseId }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
throw new Error(err?.detail || 'Failed to send')
}
setEmailSent(true)
} catch (err) {
setEmailError(err instanceof Error ? err.message : 'Failed to send email')
} finally {
setEmailSending(false)
}
}}
disabled={!emailInput.trim() || emailSending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{emailSending ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Sending...
</>
) : 'Send'}
</button>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-emerald-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>
Email sent! Check your inbox.
</div>
)}
{emailError && (
<p className="text-xs text-rose-400 mt-2">{emailError}</p>
)}
</div>
{/* Copy + Finish buttons */}
<div className="flex gap-2.5 justify-center flex-wrap">
<button onClick={copyAll} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
Copy Responses to Clipboard
<button onClick={copyAll} className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to Clipboard
</button>
<button
onClick={() => navigate('/survey/thank-you')}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
>
Finish
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { BrandLogo } from '@/components/common/BrandLogo'
export default function SurveyThankYouPage() {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Atmosphere orbs */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
<div
className="absolute"
style={{ top: '-200px', left: '-100px', width: '600px', height: '600px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(6, 182, 212, 0.03) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
<div
className="absolute"
style={{ bottom: '-150px', right: '-100px', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(52, 211, 153, 0.08) 0%, rgba(52, 211, 153, 0.02) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
</div>
{/* Top bar */}
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
<BrandLogo size="sm" />
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
</a>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-[120px] animate-fade-in-up">
{/* Success icon */}
<div className="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)', border: '1px solid rgba(52, 211, 153, 0.15)' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
</div>
<h1 className="font-heading text-[clamp(24px,4vw,32px)] font-extrabold leading-tight mb-3">
Thank You!
</h1>
<p className="text-[15px] text-muted-foreground max-w-[460px] mx-auto leading-relaxed mb-3">
Your response has been recorded. Your expertise will directly shape how FlowPilot thinks about troubleshooting.
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-10">
You can safely close this browser window now.
</p>
{/* Divider */}
<div className="mx-auto w-12 h-px mb-10" style={{ background: 'var(--glass-border)' }} />
{/* Feedback callout */}
<div
className="glass-card-static p-6 text-center max-w-[480px] mx-auto"
>
<div className="flex items-center justify-center gap-2 mb-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary font-semibold">
Have Feedback?
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
If you have any feedback unrelated to the survey, we'd love to hear from you at{' '}
<a
href="mailto:feedback@resolutionflow.com"
className="text-primary hover:underline font-medium"
>
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,24 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import {
ChevronDown,
Download,
User,
Link2,
Loader2,
Eye,
EyeOff,
Archive,
ArchiveRestore,
Trash2,
CheckSquare,
Square,
MoreHorizontal,
Circle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
return (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={8} className="p-0">
<div
className="px-6 py-5"
style={{
@@ -106,25 +122,57 @@ function ResponseRow({
response,
index,
isExpanded,
isSelected,
onToggle,
onSelect,
onMarkRead,
onArchive,
onDelete,
}: {
response: SurveyResponseDetail
index: number
isExpanded: boolean
isSelected: boolean
onToggle: () => void
onSelect: () => void
onMarkRead: () => void
onArchive: () => void
onDelete: () => void
}) {
const answeredCount = QUESTIONS.filter((q) => {
const val = response.responses[q.id]
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
}).length
const [showMenu, setShowMenu] = useState(false)
return (
<>
<tr
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
onClick={onToggle}
className={cn(
'border-b border-border/50 transition-colors cursor-pointer',
!response.is_read && 'bg-primary/[0.03]',
'hover:bg-[rgba(255,255,255,0.02)]'
)}
>
<td className="px-4 py-3 w-8">
{/* Checkbox */}
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
) : (
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
)}
</td>
{/* Unread dot */}
<td className="px-1 py-3 w-6" onClick={onToggle}>
{!response.is_read && (
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
)}
</td>
{/* Expand chevron */}
<td className="px-2 py-3 w-8" onClick={onToggle}>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
@@ -132,11 +180,11 @@ function ResponseRow({
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
<td className="px-4 py-3 text-sm text-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3" onClick={onToggle}>
{response.source === 'invite' ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
<User className="h-3 w-3" />
@@ -152,16 +200,57 @@ function ResponseRow({
</span>
)}
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
{new Date(response.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
{answeredCount} / {QUESTIONS.length}
</td>
{/* Actions */}
<td className="px-3 py-3 w-10 relative">
<button
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
>
<button
onClick={() => { onMarkRead(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{response.is_read ? 'Mark Unread' : 'Mark Read'}
</button>
<button
onClick={() => { onArchive(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
{response.archived_at ? 'Unarchive' : 'Archive'}
</button>
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
<button
onClick={() => { onDelete(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
</>
)}
</td>
</tr>
{isExpanded && <ExpandedDetail response={response} />}
</>
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
const [error, setError] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showArchived, setShowArchived] = useState(false)
const fetchData = useCallback(async () => {
try {
const result = await adminApi.listSurveyResponses(showArchived)
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}, [showArchived])
useEffect(() => {
const fetchData = async () => {
try {
const result = await adminApi.listSurveyResponses()
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
setLoading(true)
fetchData()
}, [])
}, [fetchData])
const handleExport = async () => {
setExporting(true)
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
}
}
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
try {
if (currentlyRead) {
await adminApi.markResponseUnread(id)
} else {
await adminApi.markResponseRead(id)
}
setData(prev => prev ? {
...prev,
unread: prev.unread + (currentlyRead ? 1 : -1),
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
} : prev)
} catch {
toast.error('Failed to update read status')
}
}
const handleArchive = async (id: string, currentlyArchived: boolean) => {
try {
if (currentlyArchived) {
await adminApi.unarchiveResponse(id)
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
} : prev)
} else {
await adminApi.archiveResponse(id)
if (!showArchived) {
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
} else {
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
} : prev)
}
}
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
} catch {
toast.error('Failed to update archive status')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Permanently delete this response? This cannot be undone.')) return
try {
await adminApi.deleteResponse(id)
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
toast.success('Response deleted')
} catch {
toast.error('Failed to delete response')
}
}
const handleBulkAction = async (action: string) => {
if (selectedIds.size === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
try {
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
setSelectedIds(new Set())
fetchData()
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
} catch {
toast.error('Bulk action failed')
}
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
const responses = data?.responses ?? []
if (selectedIds.size === responses.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(responses.map(r => r.id)))
}
}
// Auto-mark as read when expanding
const handleExpand = (id: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
if (newId) {
const resp = data?.responses.find(r => r.id === newId)
if (resp && !resp.is_read) {
handleMarkRead(newId, false)
}
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
title="Survey Responses"
description={`${data?.total ?? 0} total responses collected`}
action={
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
<div className="flex items-center gap-2">
{/* Archive toggle */}
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
showArchived
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
)}
>
<Archive className="h-3.5 w-3.5" />
{showArchived ? 'Showing Archived' : 'Show Archived'}
</button>
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
</div>
}
/>
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
{data?.this_week ?? 0}
</p>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
Unread
</p>
<p className={cn(
'text-2xl font-heading font-bold',
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
)}>
{data?.unread ?? 0}
</p>
</div>
</div>
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
>
<span className="text-sm text-primary font-medium">
{selectedIds.size} selected
</span>
<div className="flex-1" />
<button
onClick={() => handleBulkAction('mark_read')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Mark Read
</button>
<button
onClick={() => handleBulkAction('mark_unread')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Mark Unread
</button>
<button
onClick={() => handleBulkAction('archive')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
Clear
</button>
</div>
)}
{/* Table */}
<div className="glass-card-static overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3 w-8" />
<th className="px-2 py-3 w-8">
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</th>
<th className="px-1 py-3 w-6" />
<th className="px-2 py-3 w-8" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
#
</th>
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Answered
</th>
<th className="px-3 py-3 w-10" />
</tr>
</thead>
<tbody>
{responses.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
No survey responses yet.
<td colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
</td>
</tr>
) : (
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
response={response}
index={index}
isExpanded={expandedId === response.id}
onToggle={() =>
setExpandedId(expandedId === response.id ? null : response.id)
}
isSelected={selectedIds.has(response.id)}
onToggle={() => handleExpand(response.id)}
onSelect={() => toggleSelect(response.id)}
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
onArchive={() => handleArchive(response.id, !!response.archived_at)}
onDelete={() => handleDelete(response.id)}
/>
))
)}

View File

@@ -11,6 +11,7 @@ import {
// Public pages
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
const SurveyPage = lazy(() => import('@/pages/SurveyPage'))
const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage'))
// Standalone auth pages
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
@@ -108,6 +109,15 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
},
{
path: '/survey/thank-you',
element: (
<Suspense fallback={<PageLoader />}>
<SurveyThankYouPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (

View File

@@ -34,4 +34,17 @@ export interface RetentionSettings {
chat_retention_max_count: number | null
}
export type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
export interface ConcludeChatRequest {
outcome: ConclusionOutcome
notes?: string
}
export interface ConcludeChatResponse {
summary: string
outcome: ConclusionOutcome
concluded_at: string
}
export type { SuggestedFlow }