From e910bcc67d42fcae9bb1fd0daeb212a3d850b3f2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Mon, 27 Apr 2026 23:23:00 -0400 Subject: [PATCH] fix(escalations): wire magic-moment + claim into AssistantChatPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /pilot/:id route renders AssistantChatPage, not FlowPilotSessionPage (the latter is dead code with no active route). The earlier magic-moment integration sat in the wrong file, so clicking Pick Up from the dashboard navigated to /pilot/:id?pickup=true and AssistantChatPage just loaded the chat surface with no claim — the senior never saw the magic-moment screen and the handoff stayed unclaimed (status escalated, permanently in the queue). Adds full pickup awareness to AssistantChatPage: - ?pickup=true on entry triggers a handoff fetch via handoffsApi.listHandoffs (account-scoped, no claim required). magicState transitions loading → visible (handoff found) or loading → dismissed (no handoff or fetch failed). The dismiss path also strips ?pickup=true from the URL so a refresh doesn't re-enter loading state. - The existing selectChat-from-URL effect is gated on magicState — it skips while we're loading or showing the magic-moment so the chat surface doesn't race the claim flow. After claim it re-fires and populates messages from conversation_messages because the senior is now escalated_to_id and GET succeeds. - Magic-moment renders as full-page take-over (sidebar hidden) until Start here. handleStartHere calls handoffsApi.claimHandoff, drops ?pickup=true, and dismisses — the regular chat then loads. - Toolbar Context button (visible when magicHandoff is in memory) re-opens the screen as a dismissible overlay. Lazy-fetches the handoff when needed. Verified tsc -b clean and Vite HMR picked the file up without errors. The wire-level integration was already verified in earlier commits: listHandoffs returns the unclaimed handoff for a senior pre-claim, claimHandoff flips status escalated → active and sets escalated_to_id. Note: the prior FlowPilotSessionPage magic-moment integration is now in dead code (file is unreferenced from router). Left in place for this commit; will come out in a follow-up cleanup once we're confident the AssistantChatPage path is solid in production. Co-Authored-By: Claude Opus 4.7 --- frontend/src/pages/AssistantChatPage.tsx | 162 ++++++++++++++++++++++- 1 file changed, 157 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 2cc8aa69..f6c7e5ba 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -1,5 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { useLocation, useNavigate, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { handoffsApi } from '@/api/handoffs' +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' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' @@ -63,6 +66,21 @@ export default function AssistantChatPage() { const location = useLocation() const navigate = useNavigate() const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>() + const [searchParams, setSearchParams] = useSearchParams() + const isPickup = searchParams.get('pickup') === 'true' + // Magic-moment handoff-context screen — shown BEFORE the regular chat view + // when a senior tech picks up an escalated session via /pilot/:id?pickup=true. + // Pre-claim, the senior isn't yet escalated_to_id, so we route around the + // regular selectChat path until claim succeeds. "Start here" calls the + // /handoffs/{id}/claim endpoint which flips status to active and sets + // escalated_to_id; then we drop ?pickup=true and let selectChat run. + const [magicState, setMagicState] = useState<'inactive' | 'loading' | 'visible' | 'dismissed'>( + isPickup ? 'loading' : 'inactive', + ) + const [magicHandoff, setMagicHandoff] = useState(null) + const [overlayHandoff, setOverlayHandoff] = useState(null) + const [overlayLoading, setOverlayLoading] = useState(false) + const [claiming, setClaiming] = useState(false) const [chats, setChats] = useState([]) const [activeChatId, setActiveChatId] = useState(() => { if (urlSessionId) return urlSessionId @@ -210,12 +228,89 @@ export default function AssistantChatPage() { loadChats() }, []) - // If URL has a session ID, load it + // If URL has a session ID, load it. While the magic-moment handoff-context + // screen is loading or visible, skip selectChat — the senior doesn't yet + // own the session and the regular chat surface would race against the + // claim flow. Once magicState is 'dismissed' (post-claim, or no handoff + // found at all), this effect re-fires and selectChat runs. useEffect(() => { - if (urlSessionId && urlSessionId !== activeChatId) { - selectChat(urlSessionId) + if (!urlSessionId || urlSessionId === activeChatId) return + if (magicState === 'loading' || magicState === 'visible') return + selectChat(urlSessionId) + }, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps + + // Pickup mode entry: fetch the handoff list (account-scoped via RLS, no + // claim required) to find the latest unclaimed escalate handoff. If found, + // render the magic-moment screen. If none found (legacy sessions + // pre-unification, or the handoff was already claimed by another senior), + // dismiss and let the regular chat surface load. + useEffect(() => { + if (!isPickup || !urlSessionId || magicState !== 'loading') return + let cancelled = false + ;(async () => { + try { + const handoffs = await handoffsApi.listHandoffs(urlSessionId) + if (cancelled) return + const target = handoffs.find(h => h.intent === 'escalate' && !h.claimed_by) + if (target) { + setMagicHandoff(target) + setMagicState('visible') + } else { + setMagicState('dismissed') + // Strip ?pickup=true so a refresh doesn't re-enter the loading + // state needlessly. + setSearchParams({}) + } + } catch { + if (cancelled) return + setMagicState('dismissed') + setSearchParams({}) + } + })() + return () => { cancelled = true } + }, [isPickup, urlSessionId, magicState, setSearchParams]) + + const handleStartHere = useCallback(async () => { + if (!urlSessionId || !magicHandoff) return + setClaiming(true) + try { + await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) + // Drop ?pickup=true and dismiss the magic-moment. The session-load + // effect above will then fire because magicState !== 'loading'/'visible' + // and selectChat will populate the chat surface — the senior is now + // escalated_to_id, so GET succeeds and the conversation_messages render + // as chat history. + setSearchParams({}) + setMagicState('dismissed') + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to pick up session' + toast.error(message) + } finally { + setClaiming(false) } - }, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [urlSessionId, magicHandoff, setSearchParams]) + + const openHandoffContextOverlay = useCallback(async () => { + if (!activeChatId) return + if (magicHandoff) { + setOverlayHandoff(magicHandoff) + return + } + setOverlayLoading(true) + try { + const handoffs = await handoffsApi.listHandoffs(activeChatId) + const target = handoffs.find(h => h.intent === 'escalate') + if (target) { + setOverlayHandoff(target) + } else { + toast.info('No handoff context available for this session.') + } + } catch { + toast.error('Could not load handoff context') + } finally { + setOverlayLoading(false) + } + }, [activeChatId, magicHandoff]) // Restore session from sessionStorage on mount (when URL has no session ID) useEffect(() => { @@ -1234,6 +1329,35 @@ export default function AssistantChatPage() { // Cleanup blob URLs on unmount useEffect(() => { return () => { pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) }) } }, []) // eslint-disable-line react-hooks/exhaustive-deps + // Magic-moment handoff-context screen — full-page take-over before claim. + // Loading state shows a centered spinner. Visible state shows the screen + // with the handoff payload; "Start here" claims and dismisses, after which + // the regular chat surface renders. + if (magicState === 'loading') { + return ( + <> + +
+ +
+ + ) + } + if (magicState === 'visible' && magicHandoff) { + return ( + <> + +
+ +
+ + ) + } + return ( <> @@ -1332,6 +1456,17 @@ export default function AssistantChatPage() { {/* Desktop actions — shown when session is active and has messages */}
+ {magicHandoff && ( + + )} {activePsaTicketId && (
)