Files
resolutionflow/docs/plans/2026-03-11-session-closure.md
Michael Chihlas 416bb230e3 feat: close sessions from history page with inline popover
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>
2026-03-11 01:59:12 -04:00

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"
```