Add ability to close active sessions directly from the Session History page via an inline popover with outcome selection and optional notes. Adds two new outcomes: cancelled and resolved_externally. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
405 lines
13 KiB
Markdown
405 lines
13 KiB
Markdown
# Session Closure from History Page — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Allow engineers to close active sessions directly from the Session History page via an inline popover with outcome selection and optional notes.
|
|
|
|
**Architecture:** No new endpoints or migrations. Expand the existing `SessionOutcome` type with two new values (`cancelled`, `resolved_externally`). Add a "Close" button + popover to active session cards on the history page. The popover calls the existing `POST /sessions/{id}/complete` endpoint.
|
|
|
|
**Tech Stack:** Python/FastAPI (backend schema only), React/TypeScript (frontend UI)
|
|
|
|
---
|
|
|
|
### Task 1: Backend — Expand SessionOutcome Type
|
|
|
|
**Files:**
|
|
- Modify: `backend/app/schemas/session.py:6`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `backend/tests/test_sessions.py`, inside the existing `TestSessions` class, after the `test_complete_session` test:
|
|
|
|
```python
|
|
@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
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
|
|
|
|
Expected: FAIL — 422 validation error because `cancelled` and `resolved_externally` are not valid `SessionOutcome` values.
|
|
|
|
**Step 3: Update SessionOutcome literal**
|
|
|
|
In `backend/app/schemas/session.py`, line 6, change:
|
|
|
|
```python
|
|
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
|
```
|
|
|
|
to:
|
|
|
|
```python
|
|
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
|
```
|
|
|
|
No other backend changes needed — the `POST /sessions/{id}/complete` endpoint, the `Session` model (`VARCHAR(20)`), and the `SessionComplete` schema all work with the new values automatically.
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py::TestSessions::test_complete_session_with_cancelled_outcome tests/test_sessions.py::TestSessions::test_complete_session_with_resolved_externally_outcome -v`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 5: Run full test suite to check for regressions**
|
|
|
|
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
|
|
|
|
Expected: All session tests PASS.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add backend/app/schemas/session.py backend/tests/test_sessions.py
|
|
git commit -m "feat: add cancelled and resolved_externally session outcomes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Frontend — Update SessionOutcome Type
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/types/session.ts:4`
|
|
|
|
**Step 1: Update the TypeScript type**
|
|
|
|
In `frontend/src/types/session.ts`, line 4, change:
|
|
|
|
```typescript
|
|
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
|
```
|
|
|
|
to:
|
|
|
|
```typescript
|
|
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
|
|
Expected: Clean build, no errors.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/types/session.ts
|
|
git commit -m "feat: add cancelled and resolved_externally to frontend SessionOutcome type"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Frontend — Add Close Button and Popover to Session History
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/pages/SessionHistoryPage.tsx`
|
|
|
|
This is the main UI task. Add a "Close" button to active session cards and an inline popover with outcome selection + notes.
|
|
|
|
**Step 1: Add state and handler**
|
|
|
|
At the top of the `SessionHistoryPage` component (after existing `useState` calls around line 25), add:
|
|
|
|
```tsx
|
|
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
|
|
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
|
|
const [closeNotes, setCloseNotes] = useState('')
|
|
const [closeLoading, setCloseLoading] = useState(false)
|
|
```
|
|
|
|
Add the import for `SessionOutcome` to the existing type import on line 7:
|
|
|
|
```typescript
|
|
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
|
```
|
|
|
|
Add `useRef` to the React import on line 1:
|
|
|
|
```typescript
|
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
```
|
|
|
|
Add the close handler function inside the component, after `handleClearFilters`:
|
|
|
|
```tsx
|
|
const closePopoverRef = useRef<HTMLDivElement>(null)
|
|
|
|
const handleCloseSession = useCallback(async () => {
|
|
if (!closingSessionId || !closeOutcome) return
|
|
setCloseLoading(true)
|
|
try {
|
|
await sessionsApi.complete(closingSessionId, {
|
|
outcome: closeOutcome,
|
|
outcome_notes: closeNotes || undefined,
|
|
})
|
|
// Update local state — mark session as completed
|
|
setSessions(prev =>
|
|
prev.map(s =>
|
|
s.id === closingSessionId
|
|
? { ...s, completed_at: new Date().toISOString(), outcome: closeOutcome, outcome_notes: closeNotes || null }
|
|
: s
|
|
)
|
|
)
|
|
toast.success('Session closed')
|
|
setClosingSessionId(null)
|
|
setCloseOutcome('')
|
|
setCloseNotes('')
|
|
} catch {
|
|
toast.error('Failed to close session')
|
|
} finally {
|
|
setCloseLoading(false)
|
|
}
|
|
}, [closingSessionId, closeOutcome, closeNotes])
|
|
|
|
// Close popover on click outside
|
|
useEffect(() => {
|
|
if (!closingSessionId) return
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (closePopoverRef.current && !closePopoverRef.current.contains(e.target as Node)) {
|
|
setClosingSessionId(null)
|
|
setCloseOutcome('')
|
|
setCloseNotes('')
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [closingSessionId])
|
|
```
|
|
|
|
**Step 2: Update formatOutcomeLabel**
|
|
|
|
In `SessionHistoryPage.tsx`, update the `formatOutcomeLabel` function (around line 158) to handle new outcomes:
|
|
|
|
```tsx
|
|
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
|
if (!outcome) return 'Not set'
|
|
const labels: Record<string, string> = {
|
|
resolved: 'Resolved',
|
|
escalated: 'Escalated',
|
|
workaround: 'Workaround',
|
|
unresolved: 'Unresolved',
|
|
cancelled: 'Cancelled',
|
|
resolved_externally: 'Resolved Externally',
|
|
}
|
|
return labels[outcome] ?? outcome
|
|
}
|
|
```
|
|
|
|
**Step 3: Add outcome badge colors for new outcomes**
|
|
|
|
In the session card JSX (around line 249-258), update the outcome badge to handle new outcomes. Add these two lines inside the `cn()` call, after the `!session.outcome` line:
|
|
|
|
```tsx
|
|
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
|
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
|
|
```
|
|
|
|
**Step 4: Add Close button and popover to the Actions div**
|
|
|
|
In the session card's Actions div (around line 288-309), replace the entire `{/* Actions */}` block with:
|
|
|
|
```tsx
|
|
{/* Actions */}
|
|
<div className="relative flex gap-2">
|
|
<button
|
|
onClick={() => navigate(`/sessions/${session.id}`)}
|
|
className={cn(
|
|
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
View Details
|
|
</button>
|
|
{!session.completed_at && session.started_at && (
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setClosingSessionId(closingSessionId === session.id ? null : session.id)
|
|
setCloseOutcome('')
|
|
setCloseNotes('')
|
|
}}
|
|
className={cn(
|
|
'rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground',
|
|
closingSessionId === session.id && 'bg-accent text-foreground'
|
|
)}
|
|
>
|
|
Close
|
|
</button>
|
|
<button
|
|
onClick={() => navigate(getSessionResumePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-3 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Resume
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Close Session Popover */}
|
|
{closingSessionId === session.id && (
|
|
<div
|
|
ref={closePopoverRef}
|
|
className="absolute right-0 top-full z-20 mt-2 w-72 rounded-xl border border-border bg-card p-4 shadow-xl"
|
|
>
|
|
<p className="text-sm font-heading font-medium text-foreground mb-3">Close Session</p>
|
|
|
|
<label className="block text-xs font-label text-muted-foreground mb-1">Outcome</label>
|
|
<select
|
|
value={closeOutcome}
|
|
onChange={(e) => 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"
|
|
>
|
|
<option value="">Select outcome...</option>
|
|
<option value="resolved">Resolved</option>
|
|
<option value="escalated">Escalated</option>
|
|
<option value="workaround">Workaround</option>
|
|
<option value="unresolved">Unresolved</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
<option value="resolved_externally">Resolved Externally</option>
|
|
</select>
|
|
|
|
<label className="block text-xs font-label text-muted-foreground mb-1">Notes (optional)</label>
|
|
<textarea
|
|
value={closeNotes}
|
|
onChange={(e) => setCloseNotes(e.target.value)}
|
|
rows={2}
|
|
placeholder="Add closure notes..."
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none mb-3"
|
|
/>
|
|
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setClosingSessionId(null)
|
|
setCloseOutcome('')
|
|
setCloseNotes('')
|
|
}}
|
|
className="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleCloseSession}
|
|
disabled={!closeOutcome || closeLoading}
|
|
className={cn(
|
|
'rounded-lg px-4 py-1.5 text-sm font-medium shadow-lg shadow-primary/20 transition-opacity',
|
|
closeOutcome
|
|
? 'bg-gradient-brand text-[#101114] hover:opacity-90'
|
|
: 'bg-gradient-brand text-[#101114] opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
{closeLoading ? 'Closing...' : 'Confirm'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
**Step 5: Verify build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
|
|
Expected: Clean build, no errors.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/SessionHistoryPage.tsx
|
|
git commit -m "feat: add close session button with inline popover on history page"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Verify Full Stack
|
|
|
|
**Step 1: Run backend tests**
|
|
|
|
Run: `docker exec resolutionflow_backend pytest tests/test_sessions.py -v`
|
|
|
|
Expected: All tests PASS including the 2 new ones.
|
|
|
|
**Step 2: Run frontend build**
|
|
|
|
Run: `cd frontend && npm run build`
|
|
|
|
Expected: Clean build.
|
|
|
|
**Step 3: Manual smoke test**
|
|
|
|
1. Open http://localhost:5173/sessions
|
|
2. Find an active session (yellow dot, no completed_at)
|
|
3. Verify "Close" button appears between "View Details" and "Resume"
|
|
4. Click "Close" — popover appears below
|
|
5. Select "Cancelled" outcome, type a note
|
|
6. Click "Confirm" — session card updates with completed status + "Cancelled" badge
|
|
7. Verify "Close" and "Resume" buttons disappear (session is now completed)
|
|
8. Verify completed sessions do NOT show a "Close" button
|
|
9. Verify prepared sessions (not yet started) do NOT show a "Close" button
|
|
|
|
**Step 4: Final commit (if any adjustments needed)**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: session closure adjustments from smoke test"
|
|
```
|