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.tree import Tree
from app.models.session import Session from app.models.session import Session
from app.models.user import User 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.api.deps import get_current_active_user
from app.core.permissions import can_access_tree 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 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) @router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session( async def complete_session(
session_id: UUID, session_id: UUID,
completion_data: SessionComplete,
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)] 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.completed_at = datetime.now(timezone.utc)
session.outcome = completion_data.outcome
session.outcome_notes = completion_data.outcome_notes
await db.commit() await db.commit()
await db.refresh(session) await db.refresh(session)
return session return session

View File

@@ -45,6 +45,8 @@ class Session(Base):
nullable=True, nullable=True,
index=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) ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False) 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 uuid import UUID
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
class CustomStepSchema(BaseModel): class CustomStepSchema(BaseModel):
"""Enhanced custom step with source tracking. """Enhanced custom step with source tracking.
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
automation_used: Optional[bool] = False automation_used: Optional[bool] = False
timestamp: datetime 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) attachments: list[str] = Field(default_factory=list)
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
custom_steps: list[dict[str, Any]] = Field(default_factory=list) custom_steps: list[dict[str, Any]] = Field(default_factory=list)
started_at: datetime started_at: datetime
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
outcome: Optional[SessionOutcome] = None
outcome_notes: Optional[str] = None
ticket_number: Optional[str] = None ticket_number: Optional[str] = None
client_name: Optional[str] = None client_name: Optional[str] = None
exported: bool exported: bool
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
include_tree_info: bool = True include_tree_info: bool = True
class SessionComplete(BaseModel):
outcome: SessionOutcome
outcome_notes: Optional[str] = None
class ScratchpadUpdate(BaseModel): class ScratchpadUpdate(BaseModel):
scratchpad: str scratchpad: str

View File

@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions. for troubleshooting sessions.
""" """
import html import html
from datetime import datetime, timezone from datetime import datetime
from typing import Any
from app.models.session import Session from app.models.session import Session
from app.schemas.session import SessionExport 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: def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
"""Format duration between two datetimes as human-readable string.""" """Format duration between two datetimes as human-readable string."""
if not completed_at: if not completed_at:
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
return f"{minutes} minutes" 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: def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export.""" """Generate markdown export."""
lines = [] lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info: if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") 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')}") lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at: if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}") 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("---") 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") question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "") answer = decision.get("answer", "")
notes = decision.get("notes", "") notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"### Step {i}: {question}") lines.append(f"### Step {i}: {question}")
if answer: if answer:
lines.append(f"**Answer:** {answer}") lines.append(f"**Answer:** {answer}")
if notes: if notes:
lines.append(f"**Notes:** {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"): if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*") lines.append(f"*{decision['timestamp']}*")
lines.append("") lines.append("")
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
def generate_text_export(session: Session, options: SessionExport) -> str: def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export.""" """Generate plain text export."""
lines = [] lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info: if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") 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')}") lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at: if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}") 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("")
# Scratchpad / Evidence section # 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") question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "") answer = decision.get("answer", "")
notes = decision.get("notes", "") notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"\n{i}. {question}") lines.append(f"\n{i}. {question}")
if answer: if answer:
lines.append(f" Answer: {answer}") lines.append(f" Answer: {answer}")
if notes: if notes:
lines.append(f" Notes: {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) 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: def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export.""" """Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
outcome_label = _get_outcome_label(session)
html_parts = ['<!DOCTYPE html>', '<html>', '<head>', html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">', '<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; }', '.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }', '.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }', '.notes { font-style: italic; color: #555; }',
'.duration { color: #444; margin-top: 6px; }',
'.timestamp { font-size: 0.85em; color: #888; }', '.timestamp { font-size: 0.85em; color: #888; }',
'</style>', '</style>',
'</head>', '<body>'] '</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>') html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at: 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>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>') html_parts.append('</div>')
# Scratchpad / Evidence section # 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")) question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", "")) answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", "")) notes = html.escape(decision.get("notes", ""))
duration_seconds = _get_step_duration_seconds(decision)
html_parts.append('<div class="step">') html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>') 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>') html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes: if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>') 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"): if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>') html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>') 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: def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
lines = [] lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_description = session.tree_snapshot.get("description", "") 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("=== TROUBLESHOOTING NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}") lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Tree: {tree_name} | Date: {date_str}") 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("") lines.append("")
# Problem section # 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") question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "") answer = decision.get("answer", "")
notes = decision.get("notes", "") notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
line = f"{i}. {question}" line = f"{i}. {question}"
if answer: if answer:
line += f" -> {answer}" line += f" -> {answer}"
if duration_seconds is not None:
line += f" ({_format_step_duration(duration_seconds)})"
lines.append(line) lines.append(line)
if notes: if notes:
lines.append(f" Notes: {notes}") lines.append(f" Notes: {notes}")
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(resolution) lines.append(resolution)
else: else:
lines.append("No resolution recorded.") lines.append("No resolution recorded.")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("") lines.append("")
# Time spent # Time spent

View File

@@ -153,12 +153,34 @@ class TestSessions:
# Complete session # Complete session
response = await client.post( response = await client.post(
f"/api/v1/sessions/{session_id}/complete", f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
headers=auth_headers headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["completed_at"] is not None 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 @pytest.mark.asyncio
async def test_complete_already_completed_session( async def test_complete_already_completed_session(
@@ -175,12 +197,14 @@ class TestSessions:
await client.post( await client.post(
f"/api/v1/sessions/{session_id}/complete", f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers headers=auth_headers
) )
# Try to complete again # Try to complete again
response = await client.post( response = await client.post(
f"/api/v1/sessions/{session_id}/complete", f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers headers=auth_headers
) )
@@ -217,6 +241,59 @@ class TestSessions:
assert "EXP-001" in content # Should contain ticket number assert "EXP-001" in content # Should contain ticket number
assert "#" in content # Markdown headers 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 @pytest.mark.asyncio
async def test_export_session_text( async def test_export_session_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict self, client: AsyncClient, auth_headers: dict, test_tree: dict
@@ -291,6 +368,7 @@ class TestSessions:
# Complete first session # Complete first session
await client.post( await client.post(
f"/api/v1/sessions/{session1_id}/complete", f"/api/v1/sessions/{session1_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers headers=auth_headers
) )
@@ -449,6 +527,7 @@ class TestSessions:
await client.post( await client.post(
f"/api/v1/sessions/{session_id}/complete", f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers headers=auth_headers
) )
@@ -857,6 +936,7 @@ class TestSessions:
await client.post( await client.post(
f"/api/v1/sessions/{session_id}/complete", f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers headers=auth_headers
) )

View File

@@ -1,5 +1,5 @@
import apiClient from './client' 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 { export interface SessionListParams {
page?: number page?: number
@@ -44,8 +44,8 @@ export const sessionsApi = {
return response.data return response.data
}, },
async complete(id: string): Promise<Session> { async complete(id: string, data: SessionComplete): Promise<Session> {
const response = await apiClient.post<Session>(`/sessions/${id}/complete`) const response = await apiClient.post<Session>(`/sessions/${id}/complete`, data)
return response.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 { ContinuationModal, type DescendantNode } from './ContinuationModal'
export { ForkTreeModal } from './ForkTreeModal' export { ForkTreeModal } from './ForkTreeModal'
export { ScratchpadSidebar } from './ScratchpadSidebar' export { ScratchpadSidebar } from './ScratchpadSidebar'
export { SessionOutcomeModal } from './SessionOutcomeModal'

View File

@@ -19,9 +19,10 @@ interface UseCustomStepFlowParams {
setPathTaken: (path: string[]) => void setPathTaken: (path: string[]) => void
setDecisions: (decisions: DecisionRecord[]) => void setDecisions: (decisions: DecisionRecord[]) => void
setNotes: (notes: string) => void setNotes: (notes: string) => void
setIsCompleting: (completing: boolean) => void
setError: (error: string | null) => void setError: (error: string | null) => void
onEnterNode: (enteredAtIso: string) => void
isCompleting: boolean isCompleting: boolean
onRequestCompletion: (completionDecision: DecisionRecord, source: 'custom') => void
} }
export function useCustomStepFlow({ export function useCustomStepFlow({
@@ -36,9 +37,10 @@ export function useCustomStepFlow({
setPathTaken, setPathTaken,
setDecisions, setDecisions,
setNotes, setNotes,
setIsCompleting,
setError, setError,
onEnterNode,
isCompleting, isCompleting,
onRequestCompletion,
}: UseCustomStepFlowParams) { }: UseCustomStepFlowParams) {
const navigate = useNavigate() const navigate = useNavigate()
@@ -112,9 +114,11 @@ export function useCustomStepFlow({
// Navigate back to a previously-created custom step from the decision node // Navigate back to a previously-created custom step from the decision node
const handleNavigateToCustomStep = (customStep: CustomStep) => { const handleNavigateToCustomStep = (customStep: CustomStep) => {
const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, customStep.id] const newPath = [...pathTaken, customStep.id]
setPathTaken(newPath) setPathTaken(newPath)
setCurrentNodeId(customStep.id) setCurrentNodeId(customStep.id)
onEnterNode(enteredAt)
} }
// Called when CustomStepModal submits - show action modal instead of inserting directly // Called when CustomStepModal submits - show action modal instead of inserting directly
@@ -169,6 +173,7 @@ export function useCustomStepFlow({
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
} }
const decisionTimestamp = new Date().toISOString()
const newDecision: DecisionRecord = { const newDecision: DecisionRecord = {
node_id: customStep.id, node_id: customStep.id,
question: null, question: null,
@@ -176,7 +181,10 @@ export function useCustomStepFlow({
action_performed: `Custom Step: ${pendingStep.title}`, action_performed: `Custom Step: ${pendingStep.title}`,
notes: pendingStep.content.instructions || null, notes: pendingStep.content.instructions || null,
automation_used: false, automation_used: false,
timestamp: new Date().toISOString(), timestamp: decisionTimestamp,
entered_at: decisionTimestamp,
exited_at: decisionTimestamp,
duration_seconds: 0,
attachments: [] attachments: []
} }
@@ -188,6 +196,7 @@ export function useCustomStepFlow({
setDecisions(newDecisions) setDecisions(newDecisions)
setPathTaken(newPath) setPathTaken(newPath)
setCurrentNodeId(customStep.id) setCurrentNodeId(customStep.id)
onEnterNode(decisionTimestamp)
await sessionsApi.update(session.id, { await sessionsApi.update(session.id, {
path_taken: newPath, path_taken: newPath,
@@ -236,9 +245,11 @@ export function useCustomStepFlow({
const handleContinueToDescendant = async () => { const handleContinueToDescendant = async () => {
if (!pendingContinuationNodeId || !session) return if (!pendingContinuationNodeId || !session) return
const enteredAt = new Date().toISOString()
const newPath = [...pathTaken, pendingContinuationNodeId] const newPath = [...pathTaken, pendingContinuationNodeId]
setPathTaken(newPath) setPathTaken(newPath)
setCurrentNodeId(pendingContinuationNodeId) setCurrentNodeId(pendingContinuationNodeId)
onEnterNode(enteredAt)
setNotes('') setNotes('')
setPendingContinuationNodeId(null) setPendingContinuationNodeId(null)
@@ -259,10 +270,7 @@ export function useCustomStepFlow({
const handleCustomBranchComplete = async () => { const handleCustomBranchComplete = async () => {
if (!session) return if (!session) return
setIsCompleting(true)
setError(null) setError(null)
try {
const completionDecision: DecisionRecord = { const completionDecision: DecisionRecord = {
node_id: currentNodeId, node_id: currentNodeId,
question: null, question: null,
@@ -273,24 +281,7 @@ export function useCustomStepFlow({
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
attachments: [] attachments: []
} }
onRequestCompletion(completionDecision, 'custom')
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)
}
} }
// Fork tree with custom branch // 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) const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => { useEffect(() => {
// Always clear any previous interval before (re)initializing.
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
if (!startedAt) { if (!startedAt) {
setElapsed(null) setElapsed(null)
return 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 tick = () => {
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000)) 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() 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) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
{session.completed_at ? 'Completed' : 'In Progress'} {session.completed_at ? 'Completed' : 'In Progress'}
</span> </span>
{session.client_name && <span>Client: {session.client_name}</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> </div>
{session.outcome_notes && (
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
)}
</div> </div>
{/* Actions */} {/* Actions */}
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
Notes: {decision.notes} Notes: {decision.notes}
</p> </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"> <p className="mt-2 text-xs text-white/40">
{formatDate(decision.timestamp)} {formatDate(decision.timestamp)}
</p> </p>

View File

@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
return session.tree_snapshot?.name || 'Unknown Tree' 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 ( return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8"> <div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8"> <div className="mb-8">
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
{session.client_name} {session.client_name}
</span> </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> </div>
{/* Tree Name */} {/* Tree Name */}

View File

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

View File

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