Files
resolutionflow/docs/plans/archive/2026-03-11-session-closure.md
Michael Chihlas cbb4b25671
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
fix(ui): drop setState-in-effect in useAuthSessionExpiry
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 20:15:11 -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"