diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index f2222559..61119b46 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -3,7 +3,7 @@ from typing import Optional, Any, Literal from uuid import UUID from pydantic import BaseModel, Field, validator -SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"] +SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"] class CustomStepSchema(BaseModel): diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index 21fba9e4..763e4b1c 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -163,6 +163,53 @@ class TestSessions: assert data["outcome"] == "resolved" assert data["outcome_notes"] == "Issue fixed after restarting service" + @pytest.mark.asyncio + async def test_complete_session_with_cancelled_outcome( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test completing a session with 'cancelled' outcome.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "cancelled", "outcome_notes": "Ticket withdrawn by client"}, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["outcome"] == "cancelled" + assert data["outcome_notes"] == "Ticket withdrawn by client" + assert data["completed_at"] is not None + + @pytest.mark.asyncio + async def test_complete_session_with_resolved_externally_outcome( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test completing a session with 'resolved_externally' outcome.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved_externally"}, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["outcome"] == "resolved_externally" + assert data["completed_at"] is not None + @pytest.mark.asyncio async def test_complete_session_requires_outcome( self, client: AsyncClient, auth_headers: dict, test_tree: dict diff --git a/docs/plans/2026-03-11-session-closure-design.md b/docs/plans/2026-03-11-session-closure-design.md new file mode 100644 index 00000000..b2b416f9 --- /dev/null +++ b/docs/plans/2026-03-11-session-closure-design.md @@ -0,0 +1,60 @@ +# Session Closure from History Page — Design + +> **Date:** 2026-03-11 + +## Problem + +Active sessions on the Session History page only have "View Details" and "Resume" buttons. Engineers have no way to close out sessions that were abandoned, resolved externally, or otherwise no longer needed — without resuming the entire flow. + +## Design Decisions + +- **Outcome model:** Hybrid — reuse existing 4 outcomes (resolved, escalated, workaround, unresolved) + add 2 early-closure outcomes (cancelled, resolved_externally) +- **UX:** Inline popover anchored to a "Close" button on the session card — no modal, no slide panel +- **Scope:** Active sessions only (started but not completed). No bulk close. No AI summary generation. +- **Backend:** No new endpoints or migrations. Expand `SessionOutcome` literal type; existing `POST /sessions/{id}/complete` handles everything. + +## Data Model + +No new columns. Expand `SessionOutcome` in `backend/app/schemas/session.py`: + +```python +SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"] +``` + +`VARCHAR(20)` on `session.outcome` fits both new values (max 19 chars for `resolved_externally`). + +## UI + +### Close Button + +Appears on active session cards (`started_at` is set, `completed_at` is null), between "View Details" and "Resume": + +``` +[View Details] [Close] [Resume] +``` + +Secondary button styling (border, muted text). Not shown on prepared or completed sessions. + +### Close Popover + +Anchored below the "Close" button: + +- **Outcome selector:** ` setCloseOutcome(e.target.value as SessionOutcome)} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none mb-3" + > + + + + + + + + + + +