Implement session outcomes, step timing, and live timer fixes

This commit is contained in:
Michael Chihlas
2026-02-11 17:52:12 -05:00
parent 2a1ed4d250
commit ca4ce7cad6
15 changed files with 574 additions and 59 deletions

View File

@@ -0,0 +1,31 @@
"""add outcome fields to sessions
Revision ID: 029
Revises: 028
Create Date: 2026-02-11
Adds outcome and outcome_notes columns for session completion tracking.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "029"
down_revision: Union[str, None] = "028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True))
op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True))
op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions")
op.drop_column("sessions", "outcome_notes")
op.drop_column("sessions", "outcome")

View File

@@ -10,7 +10,16 @@ from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
from app.schemas.session import (
SessionCreate,
SessionUpdate,
SessionResponse,
SessionExport,
ScratchpadUpdate,
SaveAsTreeRequest,
SaveAsTreeResponse,
SessionComplete,
)
from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
@@ -198,6 +207,7 @@ async def update_session(
@router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session(
session_id: UUID,
completion_data: SessionComplete,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
@@ -224,6 +234,8 @@ async def complete_session(
)
session.completed_at = datetime.now(timezone.utc)
session.outcome = completion_data.outcome
session.outcome_notes = completion_data.outcome_notes
await db.commit()
await db.refresh(session)
return session

View File

@@ -45,6 +45,8 @@ class Session(Base):
nullable=True,
index=True
)
outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -3,6 +3,8 @@ from typing import Optional, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, validator
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
class CustomStepSchema(BaseModel):
"""Enhanced custom step with source tracking.
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
notes: Optional[str] = None
automation_used: Optional[bool] = False
timestamp: datetime
entered_at: Optional[datetime] = None
exited_at: Optional[datetime] = None
duration_seconds: Optional[int] = Field(None, ge=0)
attachments: list[str] = Field(default_factory=list)
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
started_at: datetime
completed_at: Optional[datetime] = None
outcome: Optional[SessionOutcome] = None
outcome_notes: Optional[str] = None
ticket_number: Optional[str] = None
client_name: Optional[str] = None
exported: bool
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
include_tree_info: bool = True
class SessionComplete(BaseModel):
outcome: SessionOutcome
outcome_notes: Optional[str] = None
class ScratchpadUpdate(BaseModel):
scratchpad: str

View File

@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from app.models.session import Session
from app.schemas.session import SessionExport
OUTCOME_LABELS = {
"resolved": "Resolved",
"escalated": "Escalated",
"workaround": "Workaround Applied",
"unresolved": "Unresolved",
}
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
"""Format duration between two datetimes as human-readable string."""
if not completed_at:
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
return f"{minutes} minutes"
def _format_step_duration(duration_seconds: int) -> str:
"""Format step duration seconds as compact human-readable text."""
if duration_seconds < 0:
return "0s"
if duration_seconds < 60:
return f"{duration_seconds}s"
hours, remainder = divmod(duration_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
if seconds > 0:
return f"{hours}h {minutes}m {seconds}s"
return f"{hours}h {minutes}m"
if seconds > 0:
return f"{minutes}m {seconds}s"
return f"{minutes}m"
def _parse_iso_datetime(value: Any) -> datetime | None:
"""Parse ISO datetime strings from JSONB with support for trailing Z."""
if isinstance(value, datetime):
return value
if not isinstance(value, str) or not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
"""Get step duration from explicit field or entered/exited timestamps."""
explicit_duration = decision.get("duration_seconds")
if isinstance(explicit_duration, (int, float)):
duration = int(explicit_duration)
if duration >= 0:
return duration
entered_at = _parse_iso_datetime(decision.get("entered_at"))
exited_at = _parse_iso_datetime(decision.get("exited_at"))
if entered_at and exited_at:
total_seconds = int((exited_at - entered_at).total_seconds())
if total_seconds >= 0:
return total_seconds
return None
def _get_outcome_label(session: Session) -> str | None:
"""Map stored outcome enum to human-friendly label."""
outcome = getattr(session, "outcome", None)
if not outcome:
return None
return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title())
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"**Outcome:** {outcome_label}")
lines.append("")
lines.append("---")
lines.append("")
@@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if duration_seconds is not None:
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Scratchpad / Evidence section
@@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
if duration_seconds is not None:
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
return "\n".join(lines)
@@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
outcome_label = _get_outcome_label(session)
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
@@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
'.duration { color: #444; margin-top: 6px; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'</style>',
'</head>', '<body>']
@@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html_parts.append(f'<p><strong>Duration:</strong> {_format_duration(session.started_at, session.completed_at)}</p>')
if outcome_label:
html_parts.append(f'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
html_parts.append('</div>')
# Scratchpad / Evidence section
@@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", ""))
duration_seconds = _get_step_duration_seconds(decision)
html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
@@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if duration_seconds is not None:
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
@@ -181,6 +266,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_description = session.tree_snapshot.get("description", "")
@@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append("=== TROUBLESHOOTING NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Tree: {tree_name} | Date: {date_str}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Problem section
@@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
line = f"{i}. {question}"
if answer:
line += f" -> {answer}"
if duration_seconds is not None:
line += f" ({_format_step_duration(duration_seconds)})"
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(resolution)
else:
lines.append("No resolution recorded.")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Time spent

View File

@@ -153,12 +153,34 @@ class TestSessions:
# Complete session
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["completed_at"] is not None
assert data["outcome"] == "resolved"
assert data["outcome_notes"] == "Issue fixed after restarting service"
@pytest.mark.asyncio
async def test_complete_session_requires_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that completion requires an outcome payload."""
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",
headers=auth_headers
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_complete_already_completed_session(
@@ -175,12 +197,14 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
# Try to complete again
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -217,6 +241,59 @@ class TestSessions:
assert "EXP-001" in content # Should contain ticket number
assert "#" in content # Markdown headers
@pytest.mark.asyncio
async def test_export_markdown_includes_outcome_and_step_duration(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test markdown export includes session outcome and per-step duration."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
headers=auth_headers
)
session_id = create_response.json()["id"]
step_timestamp = "2026-02-11T10:10:00Z"
update_response = await client.put(
f"/api/v1/sessions/{session_id}",
json={
"decisions": [{
"node_id": "root",
"question": "Is this a test?",
"answer": "Yes",
"action_performed": None,
"notes": "Validated quickly",
"automation_used": False,
"timestamp": step_timestamp,
"entered_at": "2026-02-11T10:08:30Z",
"exited_at": step_timestamp,
"duration_seconds": 90,
"attachments": []
}]
},
headers=auth_headers
)
assert update_response.status_code == 200
complete_response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
headers=auth_headers
)
assert complete_response.status_code == 200
export_response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
headers=auth_headers
)
assert export_response.status_code == 200
content = export_response.text
assert "**Outcome:** Workaround Applied" in content
assert "**Duration:**" in content
assert "**Duration:** 1m 30s" in content
@pytest.mark.asyncio
async def test_export_session_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
@@ -291,6 +368,7 @@ class TestSessions:
# Complete first session
await client.post(
f"/api/v1/sessions/{session1_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -449,6 +527,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -857,6 +936,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)

View File

@@ -1,5 +1,5 @@
import apiClient from './client'
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types'
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types'
export interface SessionListParams {
page?: number
@@ -44,8 +44,8 @@ export const sessionsApi = {
return response.data
},
async complete(id: string): Promise<Session> {
const response = await apiClient.post<Session>(`/sessions/${id}/complete`)
async complete(id: string, data: SessionComplete): Promise<Session> {
const response = await apiClient.post<Session>(`/sessions/${id}/complete`, data)
return response.data
},

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
import type { SessionOutcome } from '@/types'
interface SessionOutcomeModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise<void>
isSubmitting?: boolean
}
const OUTCOME_OPTIONS: Array<{ value: SessionOutcome; label: string; description: string }> = [
{ value: 'resolved', label: 'Resolved', description: 'Issue fully resolved in this session.' },
{ value: 'workaround', label: 'Workaround', description: 'Temporary fix applied, root cause remains.' },
{ value: 'escalated', label: 'Escalated', description: 'Handed off to another engineer/team.' },
{ value: 'unresolved', label: 'Unresolved', description: 'No fix or workaround identified yet.' },
]
export function SessionOutcomeModal({
isOpen,
onClose,
onSubmit,
isSubmitting = false,
}: SessionOutcomeModalProps) {
const [outcome, setOutcome] = useState<SessionOutcome>('resolved')
const [outcomeNotes, setOutcomeNotes] = useState('')
useEffect(() => {
if (!isOpen) return
setOutcome('resolved')
setOutcomeNotes('')
}, [isOpen])
const handleSubmit = async () => {
await onSubmit({
outcome,
outcome_notes: outcomeNotes.trim() || undefined,
})
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Session Outcome"
footer={(
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className={cn(
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
'hover:bg-white/10 hover:text-white disabled:opacity-50'
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className={cn(
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
'hover:bg-white/90 disabled:opacity-50'
)}
>
{isSubmitting ? 'Completing...' : 'Complete Session'}
</button>
</div>
)}
>
<div className="space-y-4">
<p className="text-sm text-white/70">
Select the session outcome before completion.
</p>
<div className="space-y-2">
{OUTCOME_OPTIONS.map((option) => (
<label
key={option.value}
className={cn(
'block cursor-pointer rounded-lg border border-white/10 p-3 transition-colors',
outcome === option.value ? 'border-white/30 bg-white/10' : 'hover:bg-white/[0.04]'
)}
>
<div className="flex items-start gap-3">
<input
type="radio"
name="session-outcome"
value={option.value}
checked={outcome === option.value}
onChange={() => setOutcome(option.value)}
className="mt-1 h-4 w-4"
/>
<div>
<p className="text-sm font-medium text-white">{option.label}</p>
<p className="text-xs text-white/50">{option.description}</p>
</div>
</div>
</label>
))}
</div>
<div>
<label className="block text-sm font-medium text-white">Outcome Notes (optional)</label>
<textarea
value={outcomeNotes}
onChange={(e) => setOutcomeNotes(e.target.value)}
rows={3}
placeholder="Add context for this outcome..."
className={cn(
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
'text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
</div>
</div>
</Modal>
)
}

View File

@@ -2,3 +2,4 @@ export { PostStepActionModal } from './PostStepActionModal'
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal'
export { ScratchpadSidebar } from './ScratchpadSidebar'
export { SessionOutcomeModal } from './SessionOutcomeModal'

View File

@@ -19,9 +19,10 @@ interface UseCustomStepFlowParams {
setPathTaken: (path: string[]) => void
setDecisions: (decisions: DecisionRecord[]) => void
setNotes: (notes: string) => void
setIsCompleting: (completing: boolean) => void
setError: (error: string | null) => void
onEnterNode: (enteredAtIso: string) => void
isCompleting: boolean
onRequestCompletion: (completionDecision: DecisionRecord, source: 'custom') => void
}
export function useCustomStepFlow({
@@ -36,9 +37,10 @@ export function useCustomStepFlow({
setPathTaken,
setDecisions,
setNotes,
setIsCompleting,
setError,
onEnterNode,
isCompleting,
onRequestCompletion,
}: UseCustomStepFlowParams) {
const navigate = useNavigate()
@@ -112,9 +114,11 @@ export function useCustomStepFlow({
// Navigate back to a previously-created custom step from the decision node
const handleNavigateToCustomStep = (customStep: CustomStep) => {
const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, customStep.id]
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
onEnterNode(enteredAt)
}
// Called when CustomStepModal submits - show action modal instead of inserting directly
@@ -169,6 +173,7 @@ export function useCustomStepFlow({
timestamp: new Date().toISOString()
}
const decisionTimestamp = new Date().toISOString()
const newDecision: DecisionRecord = {
node_id: customStep.id,
question: null,
@@ -176,7 +181,10 @@ export function useCustomStepFlow({
action_performed: `Custom Step: ${pendingStep.title}`,
notes: pendingStep.content.instructions || null,
automation_used: false,
timestamp: new Date().toISOString(),
timestamp: decisionTimestamp,
entered_at: decisionTimestamp,
exited_at: decisionTimestamp,
duration_seconds: 0,
attachments: []
}
@@ -188,6 +196,7 @@ export function useCustomStepFlow({
setDecisions(newDecisions)
setPathTaken(newPath)
setCurrentNodeId(customStep.id)
onEnterNode(decisionTimestamp)
await sessionsApi.update(session.id, {
path_taken: newPath,
@@ -236,9 +245,11 @@ export function useCustomStepFlow({
const handleContinueToDescendant = async () => {
if (!pendingContinuationNodeId || !session) return
const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, pendingContinuationNodeId]
setPathTaken(newPath)
setCurrentNodeId(pendingContinuationNodeId)
onEnterNode(enteredAt)
setNotes('')
setPendingContinuationNodeId(null)
@@ -259,38 +270,18 @@ export function useCustomStepFlow({
const handleCustomBranchComplete = async () => {
if (!session) return
setIsCompleting(true)
setError(null)
try {
const completionDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: 'Custom Branch Completed',
notes: notes || 'Issue resolved via custom troubleshooting steps',
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
await sessionsApi.update(session.id, {
decisions: [...decisions, completionDecision]
})
await sessionsApi.complete(session.id)
if (customSteps.length > 0) {
setShowForkModal(true)
} else {
navigate(`/sessions/${session.id}`)
}
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Please try again.')
} finally {
setIsCompleting(false)
const completionDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: 'Custom Branch Completed',
notes: notes || 'Issue resolved via custom troubleshooting steps',
automation_used: false,
timestamp: new Date().toISOString(),
attachments: []
}
onRequestCompletion(completionDecision, 'custom')
}
// Fork tree with custom branch

View File

@@ -5,12 +5,23 @@ export function useSessionTimer(startedAt: string | undefined | null): string |
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
// Always clear any previous interval before (re)initializing.
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
if (!startedAt) {
setElapsed(null)
return
}
const startTime = new Date(startedAt).getTime()
const parsedStartTime = new Date(startedAt).getTime()
// If the server timestamp is invalid or ahead of the local clock, fall back to "now"
// so the timer still starts ticking immediately for the user.
const startTime = Number.isNaN(parsedStartTime) || parsedStartTime > Date.now()
? Date.now()
: parsedStartTime
const tick = () => {
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))

View File

@@ -237,6 +237,31 @@ export function SessionDetailPage() {
return new Date(dateString).toLocaleString()
}
const formatDuration = (durationSeconds: number | null | undefined) => {
if (durationSeconds == null || durationSeconds < 0) return null
if (durationSeconds < 60) return `${durationSeconds}s`
const hours = Math.floor(durationSeconds / 3600)
const minutes = Math.floor((durationSeconds % 3600) / 60)
const seconds = durationSeconds % 60
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
}
const getTotalDuration = () => {
if (!session?.completed_at) return 'In progress'
const startedAtMs = new Date(session.started_at).getTime()
const completedAtMs = new Date(session.completed_at).getTime()
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
return formatDuration(seconds) || '0s'
}
const outcomeLabel = session?.outcome
? session.outcome === 'workaround'
? 'Workaround'
: session.outcome.charAt(0).toUpperCase() + session.outcome.slice(1)
: null
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
{session.completed_at ? 'Completed' : 'In Progress'}
</span>
{session.client_name && <span>Client: {session.client_name}</span>}
{session.completed_at && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
Duration: {getTotalDuration()}
</span>
)}
{outcomeLabel && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
Outcome: {outcomeLabel}
</span>
)}
</div>
{session.outcome_notes && (
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
)}
</div>
{/* Actions */}
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
Notes: {decision.notes}
</p>
)}
{decision.duration_seconds != null && (
<p className="mt-2 text-xs text-white/50">
Duration: {formatDuration(decision.duration_seconds)}
</p>
)}
<p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)}
</p>

View File

@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
return session.tree_snapshot?.name || 'Unknown Tree'
}
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
if (!outcome) return 'Not set'
return outcome === 'workaround'
? 'Workaround'
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
{session.client_name}
</span>
)}
{session.completed_at && (
<span
className={cn(
'rounded-full px-2.5 py-0.5 text-xs font-medium',
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300',
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 && 'bg-white/10 text-white/70'
)}
>
{formatOutcomeLabel(session.outcome)}
</span>
)}
</div>
{/* Tree Name */}

View File

@@ -5,11 +5,11 @@ import { sessionsApi } from '@/api/sessions'
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
import { useSessionTimer } from '@/hooks/useSessionTimer'
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
interface LocationState {
@@ -18,6 +18,8 @@ interface LocationState {
prefillTicketNumber?: string
}
type CompletionSource = 'standard' | 'custom'
export function TreeNavigationPage() {
const { id: treeId } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -29,10 +31,14 @@ export function TreeNavigationPage() {
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
const [notes, setNotes] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isCompleting, setIsCompleting] = useState(false)
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
const [pendingCompletionDecision, setPendingCompletionDecision] = useState<DecisionRecord | null>(null)
const [completionSource, setCompletionSource] = useState<CompletionSource>('standard')
// Session metadata (prefill from Repeat Last Session)
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
@@ -59,6 +65,42 @@ export function TreeNavigationPage() {
return null
}
const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
const enteredAtMs = new Date(enteredAtIso).getTime()
const exitedAtMs = new Date(exitedAtIso).getTime()
if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
}
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
if (!sessionData.decisions || sessionData.decisions.length === 0) {
return sessionData.started_at
}
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
}
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
const exitedAt = new Date().toISOString()
const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
setPendingCompletionDecision({
...completionDecision,
timestamp: exitedAt,
entered_at: enteredAt,
exited_at: exitedAt,
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
})
setCompletionSource(source)
setShowOutcomeModal(true)
}
const closeOutcomeModal = () => {
if (isCompleting) return
setShowOutcomeModal(false)
setPendingCompletionDecision(null)
setCompletionSource('standard')
}
// Custom step flow (creation, post-step actions, continuation, branching, forking)
const customStepFlow = useCustomStepFlow({
tree,
@@ -72,9 +114,12 @@ export function TreeNavigationPage() {
setPathTaken,
setDecisions,
setNotes,
setIsCompleting,
setError,
onEnterNode: setCurrentStepEnteredAt,
isCompleting,
onRequestCompletion: (completionDecision) => {
openCompletionModal(completionDecision, 'custom')
},
})
const handleScratchpadSave = async (content: string) => {
@@ -102,6 +147,7 @@ export function TreeNavigationPage() {
setPathTaken(sessionData.path_taken)
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
setDecisions(sessionData.decisions as DecisionRecord[])
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
setTicketNumber(sessionData.ticket_number || '')
setClientName(sessionData.client_name || '')
@@ -125,6 +171,7 @@ export function TreeNavigationPage() {
client_name: clientName || undefined,
})
setSession(newSession)
setCurrentStepEnteredAt(newSession.started_at)
setShowMetadataForm(false)
// Save for "Repeat Last Session"
safeSetItem('last-session', JSON.stringify({
@@ -147,6 +194,8 @@ export function TreeNavigationPage() {
const node = findNode(currentNodeId, tree.tree_structure)
if (!node) return
const exitedAt = new Date().toISOString()
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: node.question || null,
@@ -154,7 +203,10 @@ export function TreeNavigationPage() {
action_performed: null,
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
timestamp: exitedAt,
entered_at: enteredAt,
exited_at: exitedAt,
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
attachments: [],
}
@@ -164,6 +216,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(nextNodeId)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
try {
@@ -182,6 +235,8 @@ export function TreeNavigationPage() {
const node = findNode(currentNodeId, tree.tree_structure)
if (!node || !node.next_node_id) return
const exitedAt = new Date().toISOString()
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
const newDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
@@ -189,7 +244,10 @@ export function TreeNavigationPage() {
action_performed: actionPerformed || node.title || 'Action completed',
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
timestamp: exitedAt,
entered_at: enteredAt,
exited_at: exitedAt,
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
attachments: [],
}
@@ -199,6 +257,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(node.next_node_id)
setCurrentStepEnteredAt(exitedAt)
setNotes('')
try {
@@ -213,28 +272,44 @@ export function TreeNavigationPage() {
const handleComplete = async () => {
if (!session || !tree) return
const node = findNode(currentNodeId, tree.tree_structure)
if (!node) return
const completionDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: node.title || 'Session completed',
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
}
openCompletionModal(completionDecision, 'standard')
}
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
if (!session) return
setIsCompleting(true)
setError(null)
try {
const node = findNode(currentNodeId, tree.tree_structure)
if (node) {
const finalDecision: DecisionRecord = {
node_id: currentNodeId,
question: null,
answer: null,
action_performed: node.title || 'Session completed',
notes: notes || null,
automation_used: false,
timestamp: new Date().toISOString(),
attachments: [],
}
let finalDecisions = decisions
if (pendingCompletionDecision) {
finalDecisions = [...decisions, pendingCompletionDecision]
setDecisions(finalDecisions)
await sessionsApi.update(session.id, {
decisions: [...decisions, finalDecision],
decisions: finalDecisions,
})
}
await sessionsApi.complete(session.id)
navigate(`/sessions/${session.id}`)
await sessionsApi.complete(session.id, data)
setShowOutcomeModal(false)
setPendingCompletionDecision(null)
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
customStepFlow.setShowForkModal(true)
} else {
navigate(`/sessions/${session.id}`)
}
} catch (err) {
console.error('Failed to complete session:', err)
setError('Failed to complete session. Check console for details.')
@@ -250,6 +325,7 @@ export function TreeNavigationPage() {
setPathTaken(newPath)
setDecisions(newDecisions)
setCurrentNodeId(newPath[newPath.length - 1])
setCurrentStepEnteredAt(new Date().toISOString())
}
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
@@ -763,6 +839,13 @@ export function TreeNavigationPage() {
onFork={customStepFlow.handleForkTree}
onSkip={customStepFlow.handleSkipFork}
/>
<SessionOutcomeModal
isOpen={showOutcomeModal}
onClose={closeOutcomeModal}
onSubmit={handleSubmitOutcome}
isSubmitting={isCompleting}
/>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import type { TreeStructure } from './tree'
import type { Step, StepContent } from './step'
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
export interface DecisionRecord {
node_id: string
question: string | null
@@ -9,6 +11,9 @@ export interface DecisionRecord {
notes: string | null
automation_used: boolean
timestamp: string
entered_at?: string | null
exited_at?: string | null
duration_seconds?: number | null
attachments: string[]
}
@@ -45,6 +50,8 @@ export interface Session {
custom_steps: CustomStep[]
started_at: string
completed_at: string | null
outcome: SessionOutcome | null
outcome_notes: string | null
ticket_number: string | null
client_name: string | null
exported: boolean
@@ -72,6 +79,11 @@ export interface SessionExport {
include_tree_info?: boolean
}
export interface SessionComplete {
outcome: SessionOutcome
outcome_notes?: string
}
// Navigation state for active session
export interface SessionNavigationState {
activeSession: Session | null