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>
13 KiB
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:
@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:
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
to:
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
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:
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
to:
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
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:
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:
import type { Session, TreeListItem, SessionOutcome } from '@/types'
Add useRef to the React import on line 1:
import { useEffect, useState, useRef, useCallback } from 'react'
Add the close handler function inside the component, after handleClearFilters:
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:
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:
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:
{/* 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
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
- Open http://localhost:5173/sessions
- Find an active session (yellow dot, no completed_at)
- Verify "Close" button appears between "View Details" and "Resume"
- Click "Close" — popover appears below
- Select "Cancelled" outcome, type a note
- Click "Confirm" — session card updates with completed status + "Cancelled" badge
- Verify "Close" and "Resume" buttons disappear (session is now completed)
- Verify completed sessions do NOT show a "Close" button
- Verify prepared sessions (not yet started) do NOT show a "Close" button
Step 4: Final commit (if any adjustments needed)
git add -A
git commit -m "fix: session closure adjustments from smoke test"