feat(escalations): close out plan-locked wedge polish
Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.
1. Live AI assessment refresh on the magic-moment screen. Backend already
publishes handoff_assessment_ready when enrich_escalation_async commits;
wire the frontend listener so the senior sees the assessment populate
without a manual reopen. New event type + onAssessmentReady handler on
streamEscalations; AssistantChatPage opens a scoped SSE subscription
whenever it tracks a handoff missing its assessment, refetches on match,
and replaces magicHandoff / overlayHandoff in place. Closes the loop on
the async-assessment commit e8ba74e.
2. Suggested-step chips below the chat input. Locked design from the plan
(Codex correction). Chip strip renders above the composer post-claim
when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
the input and focuses; first send or explicit X hides for the session.
3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
set (rf-escalation-seen, capped 200). Dot top-right when not seen.
Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
Codex correction. Pick Up stops propagation so it doesn't double-fire.
4. Race-condition toast on claim conflict. The /claim endpoint previously
silently overwrote claimed_by — both seniors thought they owned the
session. New HandoffAlreadyClaimedError carries the winner's id/name/
timestamp; claim_session rejects different-user re-claims (same-user is
idempotent for double-click safety); endpoint returns 409 with
structured detail. AssistantChatPage.handleStartHere extracts and
surfaces "Already claimed by {name} {time_ago}." via toast, drops
?pickup=true, dismisses magic-moment so the loser flows back to queue.
Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -34,12 +34,18 @@
|
||||
|
||||
## Remaining work on this branch
|
||||
|
||||
1. **Visual QA in a real browser** via `/qa` — slide-in animation, tab-title flash, magic-moment layout, dissolve, full junior-escalates → senior-receives → senior-claims demo path.
|
||||
2. **Suggested-step chips below the chat input** (Codex correction, design plan locks this) — surfaces `ai_assessment_data.suggested_steps[]` as clickable chips in `FlowPilotMessageBar` that prefill the input. Threading through `FlowPilotSession` → message bar.
|
||||
3. **Snapshot expansion in `HandoffManager._generate_snapshot`** — include the recent diagnostic steps / conversation tail so the magic-moment screen's "What's been tried" section can render the actual timeline pre-claim instead of "full timeline available after pickup".
|
||||
4. **Toolbar Context button on legacy-arrival sessions** — currently the button only appears when the senior arrived via the magic-moment flow this session. Lazy-fetching the handoff list on session-load (when status was-escalated) would make it work on revisits.
|
||||
5. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d. Optional for v1 demo.
|
||||
6. **Playwright e2e** for the magic-moment demo flow (junior escalates → senior receives via SSE → senior claims → opens session). Critical for the GTM Loom not to crash mid-recording.
|
||||
1. **Visual QA + bug bash** in a real browser — full pickup demo path with the four new pieces below; this is the next active step.
|
||||
2. **Snapshot expansion in `HandoffManager._generate_snapshot`** — include the recent diagnostic steps / conversation tail so the magic-moment screen's "What's been tried" section can render the actual timeline pre-claim instead of "full timeline available after pickup".
|
||||
3. **Toolbar Context button on legacy-arrival sessions** — currently the button only appears when the senior arrived via the magic-moment flow this session. Lazy-fetching the handoff list on session-load (when status was-escalated) would make it work on revisits.
|
||||
4. **Owner-facing analytics page** at `/analytics/escalations` — period selector, conversion-rate, trend chart. ~0.5d. Optional for v1 demo.
|
||||
5. **Playwright e2e** for the magic-moment demo flow (junior escalates → senior receives via SSE → senior claims → opens session). Critical for the GTM Loom not to crash mid-recording.
|
||||
|
||||
## Just shipped (4 plan-locked items, this session)
|
||||
|
||||
- **Live AI assessment refresh on the magic-moment screen.** New `HandoffAssessmentReadyEvent` type + `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it has a tracked handoff with no AI assessment yet; on a matching event it refetches and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e`.
|
||||
- **Suggested-step chips below the chat input.** New `chipsHidden` state in `AssistantChatPage` defaulting to false; a chip strip renders above the composer when `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty and the magic-moment has dissolved. Click prefills input + focus; first send hides the strip; explicit X also hides. Per-session lifetime (Codex correction locked design).
|
||||
- **Unread 6px dot on `EscalationQueue` cards.** localStorage-persisted seen set (`rf-escalation-seen`, capped 200). Dot renders top-right of any card not yet seen. Cleared on **open (card click) or claim (Pick Up)** — NOT on hover (Codex correction). Pick Up onClick now stops propagation so the wrapper's open handler isn't double-fired.
|
||||
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user`, rejects different-user re-claims (idempotent for same-user), and raises with the winner's id/name/timestamp. Endpoint translates to 409 with structured detail. `AssistantChatPage.handleStartHere` extracts the detail, formats `"Already claimed by {name} {time_ago}."` via `timeAgo()`, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests in `test_handoff_manager.py`.
|
||||
|
||||
## Two-metric framing — read this before quoting numbers to anyone
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.core.escalation_bus import bus as escalation_bus
|
||||
from app.models.user import User
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
from app.services.handoff_manager import HandoffAlreadyClaimedError, HandoffManager
|
||||
from app.schemas.session_handoff import (
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
@@ -129,6 +129,19 @@ async def claim_handoff(
|
||||
handoff_id=handoff_id,
|
||||
claiming_user_id=current_user.id,
|
||||
)
|
||||
except HandoffAlreadyClaimedError as e:
|
||||
# Loser of the race — the API surfaces structured detail so the
|
||||
# client can render "Already claimed by {name} {time_ago}" without
|
||||
# a follow-up fetch.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"error": "already_claimed",
|
||||
"claimed_by_id": str(e.claimed_by_id),
|
||||
"claimed_by_name": e.claimed_by_name,
|
||||
"claimed_at": e.claimed_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -36,6 +36,30 @@ from app.services.notification_service import notify
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HandoffAlreadyClaimedError(Exception):
|
||||
"""Raised when a senior tries to claim a handoff another senior already won.
|
||||
|
||||
Carries the winning claimer's id, display name, and claim timestamp so the
|
||||
API layer can surface a "Already claimed by {name} {time_ago}" toast on
|
||||
the losing client. The race story is the locked design — without this
|
||||
exception the endpoint would silently overwrite `claimed_by` and both
|
||||
seniors would think they own the session.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
claimed_by_id: UUID,
|
||||
claimed_by_name: str,
|
||||
claimed_at: datetime,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"Handoff already claimed by {claimed_by_name} at {claimed_at.isoformat()}"
|
||||
)
|
||||
self.claimed_by_id = claimed_by_id
|
||||
self.claimed_by_name = claimed_by_name
|
||||
self.claimed_at = claimed_at
|
||||
|
||||
|
||||
class HandoffManager:
|
||||
"""Unified park/escalate handoff management."""
|
||||
|
||||
@@ -398,14 +422,31 @@ class HandoffManager:
|
||||
handoff_id: UUID,
|
||||
claiming_user_id: UUID,
|
||||
) -> SessionHandoff:
|
||||
"""Claim a handed-off session."""
|
||||
"""Claim a handed-off session.
|
||||
|
||||
If the handoff was already claimed by a *different* user (the race
|
||||
story: two seniors clicking Pick Up simultaneously), raise
|
||||
`HandoffAlreadyClaimedError` with the winning claimer's details so
|
||||
the API can return 409 with the data the loser's toast needs. A
|
||||
re-claim by the same user is idempotent.
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff).where(SessionHandoff.id == handoff_id)
|
||||
select(SessionHandoff)
|
||||
.options(selectinload(SessionHandoff.claimed_by_user))
|
||||
.where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
if handoff.claimed_by is not None and handoff.claimed_by != claiming_user_id:
|
||||
claimer = handoff.claimed_by_user
|
||||
raise HandoffAlreadyClaimedError(
|
||||
claimed_by_id=handoff.claimed_by,
|
||||
claimed_by_name=claimer.name if claimer else "another engineer",
|
||||
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
handoff.claimed_by = claiming_user_id
|
||||
handoff.claimed_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -189,6 +189,99 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
|
||||
assert session.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_conflict_raises_already_claimed(
|
||||
client: AsyncClient, test_user, test_admin, auth_headers, test_db
|
||||
):
|
||||
"""Two seniors claiming simultaneously: the second raises the typed
|
||||
HandoffAlreadyClaimedError carrying the winner's identity. Without this
|
||||
guard both calls would silently overwrite claimed_by — the locked
|
||||
race-condition story depends on a real conflict response."""
|
||||
from app.services.handoff_manager import (
|
||||
HandoffAlreadyClaimedError,
|
||||
HandoffManager,
|
||||
)
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
# First claim — admin wins.
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
# Second claim by a different user — owner of the original session,
|
||||
# standing in for "the other senior who lost the race."
|
||||
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.claimed_by_id == test_admin["user_data"]["id"]
|
||||
assert err.claimed_by_name # populated from User.name
|
||||
assert err.claimed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_idempotent_for_same_user(
|
||||
client: AsyncClient, test_user, test_admin, auth_headers, test_db
|
||||
):
|
||||
"""A re-claim by the user who already won is a no-op, not a conflict.
|
||||
Defends against double-clicks / network retries on the loser-side toast."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
first = await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
second = await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert first.claimed_by == second.claimed_by == test_admin["user_data"]["id"]
|
||||
|
||||
|
||||
# ─── Notification dispatch ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
HandoffCreatedEvent,
|
||||
HandoffAssessmentReadyEvent,
|
||||
EscalationStreamHandlers,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
@@ -279,6 +280,13 @@ export const aiSessionsApi = {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>
|
||||
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
|
||||
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
|
||||
} else if (
|
||||
eventType === 'handoff_assessment_ready' &&
|
||||
parsed.type === 'handoff_assessment_ready'
|
||||
) {
|
||||
handlers.onAssessmentReady?.(
|
||||
parsed as unknown as HandoffAssessmentReadyEvent,
|
||||
)
|
||||
} else if (eventType === 'ready') {
|
||||
handlers.onReady?.()
|
||||
}
|
||||
|
||||
@@ -26,6 +26,34 @@ const sortNewestFirst = (a: AISessionSummary, b: AISessionSummary) =>
|
||||
// state transition.
|
||||
const NEW_CARD_HIGHLIGHT_MS = 800
|
||||
|
||||
// localStorage key for the per-user "seen" set. Tracks session IDs the user
|
||||
// has acknowledged so the unread dot doesn't reappear on refresh. Bounded to
|
||||
// the last `SEEN_CAP` entries to avoid unbounded growth on long-lived
|
||||
// accounts.
|
||||
const SEEN_STORAGE_KEY = 'rf-escalation-seen'
|
||||
const SEEN_CAP = 200
|
||||
|
||||
function loadSeenIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SEEN_STORAGE_KEY)
|
||||
if (!raw) return new Set()
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((v): v is string => typeof v === 'string'))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenIds(ids: Set<string>): void {
|
||||
try {
|
||||
const arr = Array.from(ids).slice(-SEEN_CAP)
|
||||
localStorage.setItem(SEEN_STORAGE_KEY, JSON.stringify(arr))
|
||||
} catch {
|
||||
// localStorage unavailable / quota — silent. The dot just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
function waitTimeColor(createdAt: string): string {
|
||||
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
|
||||
if (hours >= 4) return '#f87171' // danger
|
||||
@@ -42,6 +70,20 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
const [newIds, setNewIds] = useState<Set<string>>(new Set())
|
||||
// Track count of unseen arrivals while the tab is backgrounded.
|
||||
const [unseenCount, setUnseenCount] = useState(0)
|
||||
// Per-user seen set persisted in localStorage. Cleared on open, claim, or
|
||||
// explicit dismiss (NOT on hover — Codex correction). The unread dot is
|
||||
// shown for any session id NOT in this set.
|
||||
const [seenIds, setSeenIds] = useState<Set<string>>(() => loadSeenIds())
|
||||
|
||||
const markSeen = useCallback((sessionId: string) => {
|
||||
setSeenIds(prev => {
|
||||
if (prev.has(sessionId)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(sessionId)
|
||||
saveSeenIds(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Ref mirrors the latest sessions so the SSE handler can diff without
|
||||
// re-binding on every state change.
|
||||
@@ -190,6 +232,7 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
}, [handleHandoffCreated])
|
||||
|
||||
const handlePickup = (sessionId: string) => {
|
||||
markSeen(sessionId)
|
||||
if (onPickup) {
|
||||
onPickup(sessionId)
|
||||
} else {
|
||||
@@ -197,6 +240,14 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
}
|
||||
}
|
||||
|
||||
// Click on the card body (anywhere outside Pick Up) marks the session as
|
||||
// seen — the "open" affordance from the unread-dot spec. Pick Up handles
|
||||
// its own marking via handlePickup. Hover deliberately does NOT clear
|
||||
// (Codex correction).
|
||||
const handleCardOpen = (sessionId: string) => {
|
||||
markSeen(sessionId)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -256,15 +307,26 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
<div role="region" aria-live="polite" className="space-y-3">
|
||||
{sessions.map((session) => {
|
||||
const isNew = newIds.has(session.id)
|
||||
const isUnread = !seenIds.has(session.id)
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => handleCardOpen(session.id)}
|
||||
className={cn(
|
||||
'card-flat p-3 sm:p-4 space-y-3',
|
||||
'relative card-flat p-3 sm:p-4 space-y-3 cursor-pointer',
|
||||
isNew && !prefersReducedMotion && 'animate-slide-in-bottom',
|
||||
isNew && prefersReducedMotion && 'animate-fade-in',
|
||||
)}
|
||||
>
|
||||
{/* Unread indicator: 6px dot, top-right corner. Cleared on
|
||||
open (card click) or claim (Pick Up). Persists across
|
||||
refresh via localStorage. */}
|
||||
{isUnread && (
|
||||
<span
|
||||
aria-label="Unread escalation"
|
||||
className="absolute top-2 right-2 inline-block w-1.5 h-1.5 rounded-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{session.problem_summary || 'Untitled session'}
|
||||
@@ -303,7 +365,10 @@ export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProp
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => handlePickup(session.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePickup(session.id)
|
||||
}}
|
||||
className="rounded-lg bg-primary text-white px-4 py-2.5 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Pick Up
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { handoffsApi } from '@/api/handoffs'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
@@ -81,6 +83,12 @@ export default function AssistantChatPage() {
|
||||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(null)
|
||||
const [overlayLoading, setOverlayLoading] = useState(false)
|
||||
const [claiming, setClaiming] = useState(false)
|
||||
// Codex correction (locked design): once the magic-moment dissolves, the
|
||||
// AI's `suggested_steps[]` should still be reachable as chips below the
|
||||
// composer. Click prefills the input; first send hides the strip; explicit
|
||||
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
||||
// fine because the senior can re-open the Context overlay.
|
||||
const [chipsHidden, setChipsHidden] = useState(false)
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
@@ -299,6 +307,24 @@ export default function AssistantChatPage() {
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
} catch (e: unknown) {
|
||||
// Race-condition path (locked design): the loser of the simultaneous
|
||||
// Pick Up gets a 409 with structured detail so we can name the
|
||||
// winner and approximate "how long ago." Drop the magic-moment
|
||||
// (the session is no longer theirs to claim) and let them go back
|
||||
// to the queue.
|
||||
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||
const detail = e.response.data?.detail as
|
||||
| { error?: string; claimed_by_name?: string; claimed_at?: string }
|
||||
| undefined
|
||||
if (detail?.error === 'already_claimed') {
|
||||
const name = detail.claimed_by_name || 'another engineer'
|
||||
const when = detail.claimed_at ? timeAgo(detail.claimed_at) : 'just now'
|
||||
toast.info(`Already claimed by ${name} ${when}.`)
|
||||
setSearchParams({})
|
||||
setMagicState('dismissed')
|
||||
return
|
||||
}
|
||||
}
|
||||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
@@ -328,6 +354,75 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}, [activeChatId, magicHandoff])
|
||||
|
||||
// Live-refresh the magic-moment / overlay handoff when the background AI
|
||||
// enrichment finishes. The backend publishes `handoff_assessment_ready` on
|
||||
// the escalation bus when `enrich_escalation_async` commits the assessment.
|
||||
// We subscribe while we have a handoff that is still missing its assessment
|
||||
// (the placeholder "still generating" state); on a matching event, refetch
|
||||
// the handoff list and replace state in place. The senior sees the AI
|
||||
// assessment populate without having to manually reopen the overlay.
|
||||
//
|
||||
// Account-scoped at the backend (only handoff.account_id subscribers are
|
||||
// notified). Single subscription regardless of which view (pre-claim screen
|
||||
// or post-claim overlay) is showing — both states key off the same handoff.
|
||||
const trackedHandoffId = magicHandoff?.id ?? overlayHandoff?.id ?? null
|
||||
const trackedSessionId = magicHandoff?.session_id ?? overlayHandoff?.session_id ?? null
|
||||
const assessmentMissing =
|
||||
!!trackedHandoffId &&
|
||||
!((magicHandoff ?? overlayHandoff)?.ai_assessment) &&
|
||||
!((magicHandoff ?? overlayHandoff)?.ai_assessment_data)
|
||||
|
||||
useEffect(() => {
|
||||
if (!assessmentMissing || !trackedHandoffId || !trackedSessionId) return
|
||||
const abort = new AbortController()
|
||||
let reconnectTimer: number | null = null
|
||||
let attempt = 0
|
||||
let cancelled = false
|
||||
|
||||
const refetch = async () => {
|
||||
try {
|
||||
const handoffs = await handoffsApi.listHandoffs(trackedSessionId)
|
||||
const fresh = handoffs.find(h => h.id === trackedHandoffId)
|
||||
if (!fresh || cancelled) return
|
||||
setMagicHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
|
||||
setOverlayHandoff(prev => (prev && prev.id === fresh.id ? fresh : prev))
|
||||
} catch {
|
||||
// best-effort; the user can manually reopen
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
await aiSessionsApi.streamEscalations(
|
||||
{
|
||||
onReady: () => { attempt = 0 },
|
||||
onAssessmentReady: (event) => {
|
||||
if (event.handoff_id !== trackedHandoffId) return
|
||||
void refetch()
|
||||
},
|
||||
},
|
||||
abort.signal,
|
||||
)
|
||||
if (!cancelled) reconnectTimer = window.setTimeout(connect, 1000)
|
||||
} catch (err) {
|
||||
if (cancelled || abort.signal.aborted) return
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
const delay = Math.min(30_000, 1000 * 2 ** attempt)
|
||||
attempt += 1
|
||||
reconnectTimer = window.setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
abort.abort()
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer)
|
||||
}
|
||||
}, [assessmentMissing, trackedHandoffId, trackedSessionId])
|
||||
|
||||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||
useEffect(() => {
|
||||
if (!urlSessionId && activeChatId) {
|
||||
@@ -1027,6 +1122,7 @@ export default function AssistantChatPage() {
|
||||
.map((u) => u.preview)
|
||||
setInput('')
|
||||
setPendingUploads([])
|
||||
setChipsHidden(true)
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -1721,6 +1817,47 @@ export default function AssistantChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggested-step chips (Codex correction, locked design):
|
||||
visible after the magic-moment dissolves (post-claim) so the
|
||||
senior can pull the AI's suggested next steps into the
|
||||
composer with one click. Hides on first send or explicit X. */}
|
||||
{!chipsHidden &&
|
||||
magicHandoff?.ai_assessment_data?.suggested_steps &&
|
||||
magicHandoff.ai_assessment_data.suggested_steps.length > 0 &&
|
||||
magicState === 'dismissed' && (
|
||||
<div className="px-3 sm:px-6 pt-2 shrink-0">
|
||||
<div className="max-w-3xl mx-auto flex items-start gap-2">
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground pt-1.5 shrink-0">
|
||||
Suggested
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 flex-1 min-w-0">
|
||||
{magicHandoff.ai_assessment_data.suggested_steps.map((step, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInput(step)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
className="rounded-full border border-default bg-elevated px-3 py-1 text-xs text-foreground hover:bg-accent-dim hover:text-accent-text hover:border-accent/30 transition-colors text-left max-w-full truncate"
|
||||
title={step}
|
||||
>
|
||||
{step}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChipsHidden(true)}
|
||||
aria-label="Hide suggestions"
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors shrink-0"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
|
||||
@@ -274,7 +274,18 @@ export interface HandoffCreatedEvent {
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
// Published by `enrich_escalation_async` after the background AI enrichment
|
||||
// finishes. Connected magic-moment screens use this to refetch the handoff
|
||||
// and re-render the AI assessment section in place.
|
||||
export interface HandoffAssessmentReadyEvent {
|
||||
type: 'handoff_assessment_ready'
|
||||
handoff_id: string
|
||||
session_id: string
|
||||
has_assessment: boolean
|
||||
}
|
||||
|
||||
export interface EscalationStreamHandlers {
|
||||
onReady?: () => void
|
||||
onHandoffCreated?: (event: HandoffCreatedEvent) => void
|
||||
onAssessmentReady?: (event: HandoffAssessmentReadyEvent) => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user