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>
This commit is contained in:
@@ -3,7 +3,7 @@ from typing import Optional, Any, Literal
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
||||
|
||||
|
||||
class CustomStepSchema(BaseModel):
|
||||
|
||||
@@ -163,6 +163,53 @@ class TestSessions:
|
||||
assert data["outcome"] == "resolved"
|
||||
assert data["outcome_notes"] == "Issue fixed after restarting service"
|
||||
|
||||
@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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_session_requires_outcome(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
|
||||
60
docs/plans/2026-03-11-session-closure-design.md
Normal file
60
docs/plans/2026-03-11-session-closure-design.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Session Closure from History Page — Design
|
||||
|
||||
> **Date:** 2026-03-11
|
||||
|
||||
## Problem
|
||||
|
||||
Active sessions on the Session History page only have "View Details" and "Resume" buttons. Engineers have no way to close out sessions that were abandoned, resolved externally, or otherwise no longer needed — without resuming the entire flow.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Outcome model:** Hybrid — reuse existing 4 outcomes (resolved, escalated, workaround, unresolved) + add 2 early-closure outcomes (cancelled, resolved_externally)
|
||||
- **UX:** Inline popover anchored to a "Close" button on the session card — no modal, no slide panel
|
||||
- **Scope:** Active sessions only (started but not completed). No bulk close. No AI summary generation.
|
||||
- **Backend:** No new endpoints or migrations. Expand `SessionOutcome` literal type; existing `POST /sessions/{id}/complete` handles everything.
|
||||
|
||||
## Data Model
|
||||
|
||||
No new columns. Expand `SessionOutcome` in `backend/app/schemas/session.py`:
|
||||
|
||||
```python
|
||||
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved", "cancelled", "resolved_externally"]
|
||||
```
|
||||
|
||||
`VARCHAR(20)` on `session.outcome` fits both new values (max 19 chars for `resolved_externally`).
|
||||
|
||||
## UI
|
||||
|
||||
### Close Button
|
||||
|
||||
Appears on active session cards (`started_at` is set, `completed_at` is null), between "View Details" and "Resume":
|
||||
|
||||
```
|
||||
[View Details] [Close] [Resume]
|
||||
```
|
||||
|
||||
Secondary button styling (border, muted text). Not shown on prepared or completed sessions.
|
||||
|
||||
### Close Popover
|
||||
|
||||
Anchored below the "Close" button:
|
||||
|
||||
- **Outcome selector:** `<select>` with 6 options — Resolved, Escalated, Workaround, Unresolved, Cancelled, Resolved Externally
|
||||
- **Notes:** Optional textarea (2 rows)
|
||||
- **Confirm:** `bg-gradient-brand`, disabled until outcome selected
|
||||
- **Cancel / click outside:** Closes popover
|
||||
- Glass card styling (`glass-card-static` pattern)
|
||||
|
||||
On confirm: calls `POST /sessions/{id}/complete` with `{ outcome, outcome_notes }`, updates local state, shows toast.
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
### Backend (2 files)
|
||||
1. `backend/app/schemas/session.py` — add new outcome values to `SessionOutcome`
|
||||
2. Update frontend outcome type to match
|
||||
|
||||
### Frontend (2-3 files)
|
||||
1. `frontend/src/types/` — update `SessionOutcome` TypeScript type
|
||||
2. `frontend/src/pages/SessionHistoryPage.tsx` — add Close button, popover, outcome label formatting for new values
|
||||
|
||||
No new components, endpoints, or migrations.
|
||||
404
docs/plans/2026-03-11-session-closure.md
Normal file
404
docs/plans/2026-03-11-session-closure.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { StaggerList } from '@/components/common/StaggerList'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { Session, TreeListItem } from '@/types'
|
||||
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
@@ -24,6 +24,13 @@ export function SessionHistoryPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('all')
|
||||
|
||||
// Close session popover state
|
||||
const [closingSessionId, setClosingSessionId] = useState<string | null>(null)
|
||||
const [closeOutcome, setCloseOutcome] = useState<SessionOutcome | ''>('')
|
||||
const [closeNotes, setCloseNotes] = useState('')
|
||||
const [closeLoading, setCloseLoading] = useState(false)
|
||||
const closePopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize filters from URL params
|
||||
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
||||
const ticketNumber = searchParams.get('ticket') || ''
|
||||
@@ -147,6 +154,46 @@ export function SessionHistoryPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseSession = useCallback(async () => {
|
||||
if (!closingSessionId || !closeOutcome) return
|
||||
setCloseLoading(true)
|
||||
try {
|
||||
await sessionsApi.complete(closingSessionId, {
|
||||
outcome: closeOutcome,
|
||||
outcome_notes: closeNotes || undefined,
|
||||
})
|
||||
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])
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
@@ -157,9 +204,15 @@ export function SessionHistoryPage() {
|
||||
|
||||
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||
if (!outcome) return 'Not set'
|
||||
return outcome === 'workaround'
|
||||
? 'Workaround'
|
||||
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
||||
const labels: Record<string, string> = {
|
||||
resolved: 'Resolved',
|
||||
escalated: 'Escalated',
|
||||
workaround: 'Workaround',
|
||||
unresolved: 'Unresolved',
|
||||
cancelled: 'Cancelled',
|
||||
resolved_externally: 'Resolved Externally',
|
||||
}
|
||||
return labels[outcome] ?? outcome
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -254,6 +307,8 @@ export function SessionHistoryPage() {
|
||||
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
|
||||
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
||||
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
||||
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-cyan-500/20 text-cyan-300',
|
||||
!session.outcome && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -285,7 +340,7 @@ export function SessionHistoryPage() {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
className={cn(
|
||||
@@ -295,16 +350,92 @@ export function SessionHistoryPage() {
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
{!session.completed_at && (
|
||||
<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'
|
||||
)}
|
||||
{!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"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<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)}
|
||||
title="Session outcome"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TreeStructure } from './tree'
|
||||
import type { Step, StepContent } from './step'
|
||||
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved' | 'cancelled' | 'resolved_externally'
|
||||
|
||||
export interface DecisionRecord {
|
||||
node_id: string
|
||||
|
||||
Reference in New Issue
Block a user