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

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

  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)

git add -A
git commit -m "fix: session closure adjustments from smoke test"