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' import { cn } from '@/lib/utils' import { uploadsApi } from '@/api/uploads' import type { PendingUpload } from '@/types/upload' import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session' import { PageMeta } from '@/components/common/PageMeta' import { aiSessionsApi } from '@/api/aiSessions' import { integrationsApi } from '@/api/integrations' import { useBranching } from '@/hooks/useBranching' import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatMessage } from '@/components/assistant/ChatMessage' import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow' import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview' import { ProposalBanner } from '@/components/pilot/ProposalBanner' import type { BannerMode } from '@/components/pilot/ProposalBanner' import { EscalateInterceptDialog } from '@/components/pilot/EscalateInterceptDialog' import type { InterceptChoice } from '@/components/pilot/EscalateInterceptDialog' import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel' import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt' import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip' import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab' import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog' import { ShortcutsHelpOverlay } from '@/components/pilot/ShortcutsHelpOverlay' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { useMediaQuery } from '@/hooks/useMediaQuery' import { draftTemplatesApi, accountPreferencesApi, type DraftTemplate, } from '@/api/draftTemplates' import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents' import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts' import { sessionSuggestedFixesApi, type SessionSuggestedFix, type ResolutionNotePreview as ResolutionNotePreviewData, type UserDecision, type FixOutcome, } from '@/api/sessionSuggestedFixes' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { NewTicketModal } from '@/components/tickets/NewTicketModal' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' import type { PSATicketInfo } from '@/types/integrations' interface MessageWithMeta { role: 'user' | 'assistant' content: string suggestedFlows?: SuggestedFlow[] fork?: ForkMetadata | null actions?: ActionItem[] | null questions?: QuestionItem[] | null imageUrls?: string[] } 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 [activeOptionKey, setActiveOptionKey] = useState<'continue' | 'ai' | 'own' | null>(null) // 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 [chats, setChats] = useState([]) const [activeChatId, setActiveChatId] = useState(() => { if (urlSessionId) return urlSessionId try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null } }) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [showConclude, setShowConclude] = useState(false) const [showStatusUpdate, setShowStatusUpdate] = useState(false) const branching = useBranching() const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [showLogs, setShowLogs] = useState(false) const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) // Task-lane mount restoration is gated on (a) the persisted chatId // matching whatever activeChatId resolved to, AND (b) the page not being // entered with a prefill in location.state. The prefill case means we're // about to create a brand-new session and discard the previous one's // task lane anyway — restoring it just causes the previous chat's // questions/actions to flash on the first paint before sendPrefill's // resetSessionDerivedState clears them. Same logic for the bell-icon // pickup flow (?pickup=true): the senior is entering an unrelated // session and any leftover task-lane meta from their own prior chat is // noise. Both gates collapse to "are we about to leave the previous // chat behind?" — if yes, start clean. const incomingPrefill = !!(location.state as { prefill?: string } | null)?.prefill const skipTaskLaneRestore = incomingPrefill || isPickup const [activeQuestions, setActiveQuestions] = useState(() => { if (skipTaskLaneRestore) return [] try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] } } catch { /* ignore */ } return [] }) const [activeActions, setActiveActions] = useState(() => { if (skipTaskLaneRestore) return [] try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] } } catch { /* ignore */ } return [] }) const [showTaskLane, setShowTaskLane] = useState(() => { if (skipTaskLaneRestore) return false try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId } } catch { /* ignore */ } return false }) // Task-lane owner: the chatId these in-memory questions/actions/show // values BELONG to, set every time we populate the lane. Render is gated // on `taskLaneOwnerChatId === activeChatId` so any path that flips the // active chat without clearing the lane state (in-place URL change, // mid-flight pickup, etc.) cannot leak the previous chat's task data // into the new view. The mount-time flash protection still lives in // `skipTaskLaneRestore`; this guard handles every other transition. const [taskLaneOwnerChatId, setTaskLaneOwnerChatId] = useState(() => { if (skipTaskLaneRestore) return null try { const saved = sessionStorage.getItem('rf-tasklane-meta') if (saved) { const d = JSON.parse(saved) if (typeof d.chatId === 'string' && d.chatId === activeChatId) return d.chatId } } catch { /* ignore */ } return null }) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' ) const [activeSessionStatus, setActiveSessionStatus] = useState(null) const [activePsaTicketId, setActivePsaTicketId] = useState(null) // Phase 2: "What we know" facts for the active session. Refreshed on // selectChat and after each chat send (the AI may have emitted [PROMOTE] // markers that synthesized new facts server-side). const [facts, setFacts] = useState([]) // Phase 3: active suggested fix; Phase 4 extends the preview popover to // support both Resolve and Escalate (kind-parameterized, one active at a time). const [activeFix, setActiveFix] = useState(null) const [previewKind, setPreviewKind] = useState<'resolve' | 'escalate' | null>(null) const [previewData, setPreviewData] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewError, setPreviewError] = useState(null) const [previewPosting, setPreviewPosting] = useState(false) // Debounce timer for preview refresh — Phase 3 spec calls for 500ms client- // side debounce so rapid edits don't fan out to the LLM (cache absorbs the // dups, but the request itself still costs HTTP RTT). const previewDebounceRef = useRef | null>(null) const previewOpen = previewKind !== null // Phase 5: inline Script Generator panel state. Open <=> the engineer // clicked the Suggested Fix card. Which panel renders is decided by // whether the active fix has a script_template_id. const [scriptPanelOpen, setScriptPanelOpen] = useState(false) const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false) // Phase 6: post-resolve "save as template?" queue. After Resolve succeeds // we fetch pending drafts for this session and show the modal one at a // time; the user accepts, rejects, or toggles "don't ask again", and we // advance to the next pending draft. const [templatizeQueue, setTemplatizeQueue] = useState([]) // PSA spin-off ticket flow (merged from main): linked ticket context for // pre-filling NewTicketModal, plus the modal's open state and a quick-tab hint. const [linkedTicket, setLinkedTicket] = useState(null) const [showNewTicket, setShowNewTicket] = useState(false) const [spinOffHint, setSpinOffHint] = useState(undefined) const [showOverflow, setShowOverflow] = useState(false) // Phase 7: keyboard-shortcut help overlay. const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false) // Phase 7: below 1200px the task lane collapses to a bottom drawer per the // migration spec. Above, it's the standard right-side panel. const isNarrow = useMediaQuery('(max-width: 1199px)') // Phase 8: ProposalBanner + EscalateInterceptDialog state. const [bannerCollapsed, setBannerCollapsed] = useState(false) const [postApplyMsgCount, setPostApplyMsgCount] = useState(0) const [nudgeSilenced, setNudgeSilenced] = useState(false) const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null) // Phase 9: ChatTabStrip + ScriptBuilderTab state. const [chatTab, setChatTab] = useState('chat') const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false) // Phase 8: compute the current banner mode from activeFix. // applied_at is now persisted on the server (stamped by POST /apply), // so bannerMode is derived entirely from server state — no client-side flag. const bannerMode: BannerMode | null = (() => { if (!activeFix) return null if (activeFix.status === 'dismissed') return null if (activeFix.ai_outcome_proposal) return 'ai_confirming' if (activeFix.status === 'applied_partial') return 'partial' if (activeFix.status === 'applied_pending') return 'pending' if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null if (activeFix.applied_at) { if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge' return 'verifying' } return 'proposed' })() // Phase 9: show the tab strip when the fix needs a script drafted (no template, // no drafted script yet, and still in a live state). const showTabStrip = activeFix != null && activeFix.status !== 'dismissed' && activeFix.status !== 'applied_success' && activeFix.status !== 'applied_failed' && !activeFix.script_template_id && !activeFix.ai_drafted_script // Defensive: if the strip hides (fix resolved/dismissed/script-drafted), // snap back to the Chat tab so the user doesn't land on a blank panel. useEffect(() => { if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat') }, [showTabStrip, chatTab]) const toggleSidebarCollapse = () => { const next = !sidebarCollapsed setSidebarCollapsed(next) localStorage.setItem('rf-chat-sidebar-collapsed', String(next)) } const messagesEndRef = useRef(null) const inputRef = useRef(null) const fileInputRef = useRef(null) const dragCounterRef = useRef(0) const prefillHandledRef = useRef(false) // Tracks the most recently requested active chat ID so in-flight selectChat // calls that complete after the user switches chats don't clobber new state. const currentChatRef = useRef(activeChatId) // Tracks which URL chatIds we've already loaded via selectChat in this // page lifecycle. Replaces the old `urlSessionId === activeChatId` gate, // which was buggy after commit 8914391 made activeChatId initialize from // urlSessionId — they MATCH on mount, so the gate bailed and selectChat // never fired for fresh entries (notably the bell-icon → ?pickup=true // path: post-claim the chat surface had no messages and the senior // landed on a blank pane). const loadedChatIdsRef = useRef>(new Set()) const guardCurrentChat = useCallback((expectedChatId: string, source: string) => { if (currentChatRef.current === expectedChatId) return true console.warn('[AssistantChat] Discarded stale async result', { source, expectedChatId, currentChatId: currentChatRef.current, }) return false }, []) // Persist active chat ID to sessionStorage useEffect(() => { try { if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId) else sessionStorage.removeItem('rf-active-chat-id') } catch { /* ignore */ } }, [activeChatId]) // Load chat list from ai_sessions useEffect(() => { loadChats() }, []) // 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. // // The dedupe is on a "have we loaded this URL session yet" ref instead // of comparing to activeChatId — activeChatId now initializes from // urlSessionId, so the old comparison short-circuited fresh mounts and // selectChat never fired. The ref clears nothing on its own; if you // need to force a reload, call selectChat directly. useEffect(() => { if (!urlSessionId) return if (magicState === 'loading' || magicState === 'visible') return if (loadedChatIdsRef.current.has(urlSessionId)) return loadedChatIdsRef.current.add(urlSessionId) 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 handleContinue = useCallback(async () => { if (!urlSessionId || !magicHandoff) return setActiveOptionKey('continue') try { await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) setSearchParams({}) setMagicState('dismissed') void loadChats() } catch (e: unknown) { 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 { setActiveOptionKey(null) } }, [urlSessionId, magicHandoff, setSearchParams]) const handleOwnThing = useCallback(async () => { if (!urlSessionId || !magicHandoff) return setActiveOptionKey('own') try { await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) setSearchParams({}) setMagicState('dismissed') void loadChats() setTimeout(() => inputRef.current?.focus(), 300) } catch (e: unknown) { 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 { setActiveOptionKey(null) } }, [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]) // 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) { selectChat(activeChatId) } }, []) // eslint-disable-line react-hooks/exhaustive-deps // Handle prefill from command palette / dashboard handoff useEffect(() => { const state = location.state as { prefill?: string; uploadIds?: string[] } | null const prefill = state?.prefill const uploadIds = state?.uploadIds if (!prefill || prefillHandledRef.current) return prefillHandledRef.current = true navigate(location.pathname, { replace: true, state: {} }) const sendPrefill = async () => { // Clear stale task lane from previous session resetSessionDerivedState() setActiveSessionStatus('active') setActivePsaTicketId(null) try { const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: prefill }, }) const chatItem: ChatListItem = { id: session.session_id, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) // Keep the in-flight guard ref in sync. Without this, currentChatRef // stays at its mount-time value (often a stale id from sessionStorage // or null), so subsequent handleSend / handleTaskSubmit calls bail at // their `currentChatRef.current !== sentForChatId` check and the AI // response is silently dropped. currentChatRef.current = session.session_id setMessages([{ role: 'user', content: prefill }]) setLoading(true) const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: prefill, upload_ids: uploadIds?.length ? uploadIds : undefined, }) setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, ]) setChats(prev => prev.map(c => c.id === session.session_id ? { ...c, message_count: 2, title: prefill.slice(0, 100), updated_at: new Date().toISOString() } : c ) ) // Show task lane if AI sent questions or actions if (response.fork && session.session_id) { branching.loadBranches(session.session_id) } const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) setTaskLaneOwnerChatId(session.session_id) } // Refetch facts + active fix — the AI may have emitted markers. refreshSessionDerived(session.session_id) } catch { toast.error('Failed to start AI conversation') } finally { setLoading(false) } } sendPrefill() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Render gate: the in-memory task-lane data is shown only when the chatId // it belongs to (taskLaneOwnerChatId) matches activeChatId. Any path that // flips activeChatId without clearing the lane state — in-place URL // navigation, mid-flight pickup, HMR — produces a window where ownerChatId // still tags the previous chat. The render gate keeps the lane hidden // through that window until reset+repopulate runs for the new chat. const taskLaneIsForActiveChat = taskLaneOwnerChatId !== null && taskLaneOwnerChatId === activeChatId // Persist task lane metadata to sessionStorage. The chatId field tags // ownership — the chatId these questions/actions belong to, NOT the // currently-active chat. Writing activeChatId here was the original bug: // when activeChatId flipped to B but activeQuestions still had A's data, // the snapshot stamped {chatId: B, questions: [A's]} and a subsequent // restore would happily render A's data for B. useEffect(() => { try { sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ show: showTaskLane, chatId: taskLaneOwnerChatId, questions: activeQuestions, actions: activeActions, })) } catch { /* ignore */ } }, [showTaskLane, taskLaneOwnerChatId, activeQuestions, activeActions]) // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Phase 5: Cmd+K → "Open inline Script Generator". Only acts when there // is an active suggested fix on this session — otherwise we'd open an // empty panel. useEffect(() => { const handler = () => { if (activeFix) { setScriptPanelOpen(true) } else { toast.info('No active suggested fix yet — wait for the AI to propose a resolution.') } } window.addEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener) return () => window.removeEventListener(PILOT_INLINE_SCRIPT_EVENT, handler as EventListener) }, [activeFix, activeChatId]) const loadChats = async () => { try { const sessions = await aiSessionsApi.listSessions({ session_type: 'chat', limit: 100 }) setChats(sessions.map(s => ({ id: s.id, title: s.title || s.problem_summary || 'New Chat', message_count: s.step_count, pinned: false, created_at: s.created_at, updated_at: s.created_at, problem_summary: s.problem_summary, psa_ticket_id: s.psa_ticket_id, status: s.status, }))) } catch { // silently handle } } // Single source of truth for "wipe every per-chat UI state field" before // switching to a different chat. Called from selectChat, handleNewChat, // sendPrefill, and handleResumeNew so adding new chat-scoped state in future // phases only requires touching this one helper. Forgetting to clear a field // leaks the previous chat's data into the new one — first noticed as a task // lane regression (Phase 5), surfaced again as the conversation pane showing // the previous chat's messages while the sidebar entry said "0 messages". // `messages` belongs in here too: selectChat clears it asynchronously when // getSession returns, but the gap between switching and that response is // exactly when the previous chat's content stays visible. const resetSessionDerivedState = useCallback(() => { setMessages([]) setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) setTaskLaneOwnerChatId(null) setFacts([]) setActiveFix(null) setPreviewKind(null) setPreviewData(null) setPreviewError(null) setPreviewPosting(false) setScriptPanelOpen(false) // Phase 8: banner state reset setBannerCollapsed(false) setPostApplyMsgCount(0) setNudgeSilenced(false) setEscalateIntercept(null) // Phase 9: tab strip reset setChatTab('chat') setScriptBuilderHasProgress(false) // Belt-and-braces: also wipe the persisted task-lane meta. Without this, // a remount or page reload before the next AI response can re-hydrate // the previous session's questions/actions from sessionStorage even // though the in-memory state has been cleared. The persistence effect // re-saves on the next state change anyway, so the only window where // sessionStorage is empty is between this reset and the next response — // which is exactly the window where stale-tag leakage was happening. try { sessionStorage.removeItem('rf-tasklane-meta') } catch { /* ignore */ } }, []) // Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat // and after each chat send, because the AI may have emitted [PROMOTE] markers // that synthesized new facts server-side (see unified_chat_service. // _persist_promote_items). const refreshFacts = useCallback(async (chatId: string) => { try { const list = await sessionFactsApi.list(chatId) // Guard: discard stale fetch if the user switched chats mid-flight. if (!guardCurrentChat(chatId, 'refreshFacts')) return setFacts(list) // Auto-open the task lane when the session has facts so the engineer // can see them — without this, a session with only facts (no open // questions) would hide the lane and the facts would be invisible. // Tag ownership too so the lane render gate accepts it as belonging // to the active chat (the gate is `taskLaneOwnerChatId === activeChatId`). if (list.length > 0) { setShowTaskLane(true) setTaskLaneOwnerChatId(chatId) } } catch { // Best-effort — facts are accessory state. Surfacing a toast on every // refetch failure would be noisy; the empty state explains the absence. } }, [guardCurrentChat]) // Phase 3 — active suggested fix + resolution-note preview. // Declared BEFORE refreshSessionDerived / handleAddNote so the useCallback // dep arrays don't hit a temporal dead zone on React's synchronous render. const refreshActiveFix = useCallback(async (chatId: string) => { try { const fix = await sessionSuggestedFixesApi.getActive(chatId) if (!guardCurrentChat(chatId, 'refreshActiveFix')) return setActiveFix((prev) => { // If the active fix changed (AI emitted a new SUGGEST_FIX that // superseded the prior), close the script panel so the engineer // isn't acting on stale draft state. if (prev?.id !== fix?.id) setScriptPanelOpen(false) return fix }) } catch { // No-fix-yet (404) is normalized to null inside the client. Genuine // failures stay silent — accessory state, not load-bearing. } }, [guardCurrentChat]) // Kind-aware preview fetch: Resolve hits /resolution-note/preview, // Escalate hits /escalation-package/preview. They're cached separately // on the backend, so switching kinds never returns stale markdown. const refreshPreview = useCallback(async (chatId: string, kind?: 'resolve' | 'escalate') => { const effectiveKind = kind ?? previewKind if (!effectiveKind) return setPreviewLoading(true) setPreviewError(null) try { const p = effectiveKind === 'resolve' ? await sessionSuggestedFixesApi.getResolutionNotePreview(chatId) : await sessionSuggestedFixesApi.getEscalationPackagePreview(chatId) if (!guardCurrentChat(chatId, 'refreshPreview')) return setPreviewData(p) } catch (err: unknown) { const status = (err as { response?: { status?: number } })?.response?.status setPreviewError( status === 502 ? 'AI provider error drafting the note. Try again in a few seconds.' : 'Could not load preview.', ) } finally { setPreviewLoading(false) } }, [guardCurrentChat, previewKind]) // Trigger preview refresh with a 500ms debounce. The backend cache short- // circuits same-state calls, but the network round-trip is still avoidable // when the user is typing quickly (e.g. editing a fact). const schedulePreviewRefresh = useCallback((chatId: string) => { if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current) previewDebounceRef.current = setTimeout(() => { if (previewOpen && currentChatRef.current === chatId) { refreshPreview(chatId) } }, 500) }, [previewOpen, refreshPreview]) // Phase 3: convenience helper — refresh fact list, active fix, and (if open) // schedule a preview refresh. Called after every chat send so the new state // (PROMOTE-synthesized facts, new SUGGEST_FIX) appears in the lane. const refreshSessionDerived = useCallback(async (chatId: string) => { await Promise.all([refreshFacts(chatId), refreshActiveFix(chatId)]) if (previewOpen) schedulePreviewRefresh(chatId) }, [refreshFacts, refreshActiveFix, previewOpen, schedulePreviewRefresh]) const handleAddNote = async (text: string, summary: string | null) => { if (!activeChatId) return try { const fact = await sessionFactsApi.create(activeChatId, { text, summary }) setFacts(prev => [...prev, fact]) schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to add note') } } const handleUpdateFact = async (factId: string, text: string, summary: string | null) => { if (!activeChatId) return try { const updated = await sessionFactsApi.update(activeChatId, factId, { text, summary }) setFacts(prev => prev.map(f => f.id === factId ? updated : f)) schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to update fact') } } const handleDeleteFact = async (factId: string) => { if (!activeChatId) return try { await sessionFactsApi.remove(activeChatId, factId) setFacts(prev => prev.filter(f => f.id !== factId)) schedulePreviewRefresh(activeChatId) } catch { toast.error('Failed to remove fact') } } // Phase 5: handle a path choice from NoTemplateDialog. one_off and // draft_template just record the decision (returning the rendered script // for display); build_template returns a redirect_path to the Script // Builder, which we navigate to. const handleScriptDecision = async ( decision: UserDecision, options: { editedScript: string; parametersUsed: Record }, ) => { if (!activeChatId || !activeFix) return setScriptDecisionBusy(true) try { const out = await sessionSuggestedFixesApi.recordDecision( activeChatId, activeFix.id, decision, { editedScript: options.editedScript, parametersUsed: options.parametersUsed }, ) // Decision endpoint bumps state_version — reflect in preview. schedulePreviewRefresh(activeChatId) if (decision === 'build_template' && out.redirect_path) { navigate(out.redirect_path) return } if (decision === 'one_off') { toast.success('Recorded as one-off — script not added to library') } else if (decision === 'draft_template') { toast.success('Draft template queued — review after Resolve') } // Phase 9 §5: one_off and draft_template declare a run ("Run now, …"). // Stamp applied_at to transition the fix into Verifying. // build_template does NOT run — no stamp. if (decision === 'one_off' || decision === 'draft_template') { try { const updated = await sessionSuggestedFixesApi.applyFix( activeChatId, activeFix.id, ) setActiveFix(updated) } catch { /* non-fatal: engineer can still mark outcome later */ } } // Keep the panel open so the engineer can copy the rendered script. } catch { toast.error('Failed to record decision') } finally { setScriptDecisionBusy(false) } } // handleOpenPreview is declared before handleSetOutcome so it can be listed // as a useCallback dep without a temporal dead zone. const handleOpenPreview = useCallback((kind: 'resolve' | 'escalate') => { if (!activeChatId) return // Opening a different kind clobbers the cached markdown so the popover // doesn't flash stale content while the new kind fetches. if (previewKind !== kind) setPreviewData(null) setPreviewKind(kind) setPreviewError(null) refreshPreview(activeChatId, kind) }, [activeChatId, previewKind, refreshPreview]) // Phase 9: handleApplyFix — routes to the appropriate surface based on // fix state. applyFix() call site moves to Task 13 (handleScriptDecision // and TemplateMatchPanel.onMarkRun). const handleApplyFix = useCallback(() => { if (!activeFix) return if (activeFix.script_template_id) { // TemplateMatchPanel is mounted inside TaskLane.bottomSlot, so the // lane must be visible for the panel to render. On fresh sessions // (no questions/facts) the lane defaults closed, so we open it here. // Tag ownership to the current active chat so the lane render gate // (taskLaneOwnerChatId === activeChatId) accepts it. setShowTaskLane(true) if (activeChatId) setTaskLaneOwnerChatId(activeChatId) setScriptPanelOpen(true) return } if (activeFix.ai_drafted_script) { setScriptPanelOpen(true) // InlineNoTemplateDialog, now in chat region (Step 5) return } // No draft, no template — route to the Script Builder tab. setChatTab('script_builder') }, [activeFix, activeChatId]) // Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the // ProposalBanner transitions from Proposed to Verifying. Shared useCallback so // both render sites (narrow-drawer + side-panel) are identical. const handleMarkRun = useCallback(async () => { if (!activeFix || !activeChatId) return try { const updated = await sessionSuggestedFixesApi.applyFix( activeChatId, activeFix.id, ) setActiveFix(updated) setScriptPanelOpen(false) } catch { /* non-fatal: engineer can still mark outcome later */ } }, [activeFix, activeChatId]) // Phase 8: record a terminal outcome for the active fix. Updates local state // on success. For applied_success also opens the Resolve preview. const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => { if (!activeChatId || !activeFix) return try { const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes) setActiveFix(updated) // Banner and script panel are linked surfaces: once an outcome is // recorded, the script-execution affordance has done its job, so close // it alongside the banner state transition. setScriptPanelOpen(false) // Reset apply tracking state since we now have a terminal outcome. setPostApplyMsgCount(0) setNudgeSilenced(false) if (outcome === 'applied_success') { // Open the Resolve note preview so the engineer can post to PSA. handleOpenPreview('resolve') } } catch (err: unknown) { const status = (err as { response?: { status?: number; data?: { detail?: string } } })?.response?.status if (status === 409) { toast.warning('Outcome already recorded — session may already be in a terminal state.') } else { toast.error('Failed to record outcome') } } }, [activeChatId, activeFix, handleOpenPreview]) // Phase 8: accept the AI-proposed outcome. Translates AI proposal outcome // names to FixOutcome values, then delegates to handleSetOutcome. // For partial, a non-empty notes string is required by the backend (400 on // empty). Fall back to a generic note if the AI's reason is blank. const handleAcceptAIProposal = useCallback(async () => { if (!activeFix?.ai_outcome_proposal) return const { outcome, reason } = activeFix.ai_outcome_proposal const fixOutcome: FixOutcome = outcome === 'success' ? 'applied_success' : outcome === 'failure' ? 'applied_failed' : 'applied_partial' const notes = fixOutcome === 'applied_partial' ? (reason?.trim() || 'Partially applied per AI detection') : fixOutcome === 'applied_failed' ? reason?.trim() || undefined : undefined await handleSetOutcome(fixOutcome, notes) }, [activeFix, handleSetOutcome]) // Phase 8: reject the AI proposal — persist the rejection to the server so // the banner does not re-surface on the next refreshSessionDerived call. // Falls back to a local-state clear on error (non-fatal: banner may re-arm // on the next refetch, matching the previous behaviour). const handleRejectAIProposal = useCallback(async () => { if (!activeFix || !activeChatId) return try { const updated = await sessionSuggestedFixesApi.clearAIProposal(activeChatId, activeFix.id) setActiveFix(updated) } catch { // Non-fatal fallback: clear locally so the banner disappears immediately. setActiveFix({ ...activeFix, ai_outcome_proposal: null }) } }, [activeFix, activeChatId]) // Phase 8: silence the nudge banner without recording an outcome. const handleSilenceNudge = useCallback(() => { setNudgeSilenced(true) setPostApplyMsgCount(0) }, []) // Phase 8: Escalate intercept — capture fix outcome before proceeding. // Wraps the existing Escalate click (which opens ConcludeSessionModal). const handleEscalateClick = useCallback(() => { const inVerifyState = activeFix && ( (!!activeFix.applied_at && activeFix.status === 'proposed') || activeFix.status === 'applied_partial' || activeFix.status === 'applied_pending' ) if (inVerifyState && activeFix) { setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title }) return } setShowConclude(true) }, [activeFix]) const handleInterceptChoice = useCallback(async (choice: InterceptChoice, notes?: string) => { const stored = escalateIntercept if (!stored || !activeChatId) { setEscalateIntercept(null) return } const outcomeToSend: FixOutcome = choice === 'never_applied' ? 'dismissed' : choice try { const updated = await sessionSuggestedFixesApi.patchOutcome( activeChatId, stored.fixId, outcomeToSend, notes, ) setActiveFix(updated) setEscalateIntercept(null) setShowConclude(true) } catch (err) { // applied_partial without notes (or any other 4xx) must surface — the // previous silent catch let engineers believe the partial outcome was // recorded while it was rejected server-side. const message = choice === 'applied_partial' ? 'Couldn’t record the partial outcome. Add a short note and try again.' : 'Couldn’t record the outcome before escalating. Try again.' toast.error(message) // Keep the intercept open so the engineer can retry (partial path can // re-enter the notes step from the dialog). if (import.meta.env.DEV) console.warn('[AssistantChat] intercept outcome failed:', err) } }, [activeChatId, escalateIntercept]) // Phase 8: Resolve click — auto-mark applied_success if in verifying/pending state // before opening the resolution note preview. const handleResolveClick = useCallback(async () => { const shouldMarkFixSuccessful = activeFix && activeFix.applied_at && (activeFix.status === 'proposed' || activeFix.status === 'applied_pending') && activeChatId if (shouldMarkFixSuccessful) { try { const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success') setActiveFix(updated) } catch { // Non-fatal; user can still resolve. } } setShowConclude(true) }, [activeChatId, activeFix]) const handleClosePreview = () => { setPreviewKind(null) setPreviewError(null) } const handleConfirmPost = async (markdown: string) => { if (!activeChatId || !previewKind) return setPreviewPosting(true) try { const out = previewKind === 'resolve' ? await sessionSuggestedFixesApi.postResolutionNote(activeChatId, markdown) : await sessionSuggestedFixesApi.postEscalationPackage(activeChatId, markdown) setActiveSessionStatus(out.session_status) if (out.outcome === 'resolved') { toast.success( out.verified_status_id ? `Posted to ${previewData?.target_ticket_ref ?? 'PSA'} · status ${out.verified_status_name}` : `Posted to ${previewData?.target_ticket_ref ?? 'PSA'}${out.status_transition_skipped_reason ? ' · status unchanged' : ''}`, ) } else if (out.outcome === 'escalated') { toast.success( out.verified_status_id ? `Escalated · ${previewData?.target_ticket_ref ?? 'PSA'} status ${out.verified_status_name}` : `Escalated · handoff posted to ${previewData?.target_ticket_ref ?? 'PSA'}`, ) } else if (out.outcome === 'resolved_local') { toast.success('Session resolved locally (no PSA ticket linked)') } else if (out.outcome === 'escalated_local') { toast.success('Session escalated locally (no PSA ticket linked)') } handleClosePreview() // Phase 6: on a successful Resolve (either external or local), check // for pending draft_templates rows created by the Phase 5 three-option // dialog. Show the TemplatizePrompt modal iff: // - the account preference hasn't opted out // - the session has at least one pending draft // Escalate doesn't trigger this flow — only resolution. if (out.outcome === 'resolved' || out.outcome === 'resolved_local') { try { const prefs = await accountPreferencesApi.get() if (prefs.preferences.templatize_prompt_enabled === false) return const drafts = await draftTemplatesApi.list(true) const forThisSession = drafts.filter( (d) => d.source_session_id === activeChatId, ) if (forThisSession.length > 0) setTemplatizeQueue(forThisSession) } catch { // Soft-fail: the Resolve itself succeeded. A missing preference // or list fetch is not worth blocking the success toast. } } } catch (err: unknown) { console.error('[AssistantChat] confirm post failed:', err) const errResp = (err as { response?: { status?: number; data?: { detail?: string } } })?.response const status = errResp?.status const detail = errResp?.data?.detail if (status === 502) { toast.error(detail || 'PSA posted partially — see server logs.') } else if (status === 409) { toast.warning(detail || 'Session is already in that state.') } else { toast.error('Could not post. Please try again.') } } finally { setPreviewPosting(false) } } const selectChat = useCallback(async (chatId: string) => { currentChatRef.current = chatId setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available resetSessionDerivedState() setActiveSessionStatus(null) setActivePsaTicketId(null) // Fire facts + active-fix fetches in parallel with session detail. refreshSessionDerived(chatId) try { const detail = await aiSessionsApi.getSession(chatId) // Guard: if the user switched to a different chat while this API call was // in flight (e.g. clicked "New Chat"), discard stale results so we don't // clobber the new session's task lane state. if (!guardCurrentChat(chatId, 'selectChat')) return setActiveSessionStatus(detail.status) setActivePsaTicketId(detail.psa_ticket_id) if (detail.psa_ticket_id) { integrationsApi.getTicket(detail.psa_ticket_id) .then(ticket => { if (!guardCurrentChat(chatId, 'selectChat.ticket')) return setLinkedTicket(ticket) }) .catch(() => {}) } else { setLinkedTicket(null) } setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', content: m.content, })) ) // Restore task lane from persisted state if (detail.pending_task_lane) { const q = detail.pending_task_lane.questions || [] const a = detail.pending_task_lane.actions || [] if (q.length > 0 || a.length > 0) { // Pre-load user's saved responses into sessionStorage BEFORE setting props // so TaskLane can restore them on mount/prop-change const responses = detail.pending_task_lane.responses if (responses && responses.length > 0) { try { sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) } catch { /* ignore */ } } setActiveQuestions(q) setActiveActions(a) setShowTaskLane(true) setTaskLaneOwnerChatId(chatId) } } } catch { setMessages([]) } }, [guardCurrentChat, refreshSessionDerived, resetSessionDerivedState]) const handleAIAnalysis = useCallback(async () => { if (!urlSessionId || !magicHandoff) return setActiveOptionKey('ai') const sentForChatId = urlSessionId try { await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id) loadedChatIdsRef.current.add(urlSessionId) setSearchParams({}) setMagicState('dismissed') void loadChats() await selectChat(urlSessionId) if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.afterSelect')) return const assessment = magicHandoff.ai_assessment_data const snapshot = magicHandoff.snapshot as Record const problemSummary = (snapshot.problem_summary as string) || 'Untitled session' const stepCount = (snapshot.step_count as number) ?? 0 const lines: string[] = [ `I just picked up this escalated session. Here's what's known so far:`, ``, `**Problem:** ${problemSummary}`, ] if (assessment?.likely_cause) { lines.push(`**Likely cause:** ${assessment.likely_cause}`) } if (assessment?.what_we_know && assessment.what_we_know.length > 0) { lines.push(`**What we know:**`) assessment.what_we_know.forEach(fact => lines.push(`- ${fact}`)) } if (stepCount > 0) { lines.push(`**Steps on record:** ${stepCount} diagnostic steps.`) } if (magicHandoff.engineer_notes) { lines.push(`**Engineer notes:** ${magicHandoff.engineer_notes}`) } lines.push(``, `Please analyze this and give me fresh diagnostic steps to try.`) const briefing = lines.join('\n') setMessages(prev => [...prev, { role: 'user', content: briefing }]) setLoading(true) const response = await aiSessionsApi.sendChatMessage(urlSessionId, { message: briefing }) if (!guardCurrentChat(sentForChatId, 'handleAIAnalysis.chatResponse')) return setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions, }, ]) const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { clearTaskState(urlSessionId) setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) setTaskLaneOwnerChatId(urlSessionId) } } catch (e: unknown) { 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 start AI analysis' toast.error(message) } finally { setActiveOptionKey(null) setLoading(false) } }, [guardCurrentChat, urlSessionId, magicHandoff, setSearchParams, selectChat]) const handleNewChat = async () => { // Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit // for the previous session sees a mismatch and bails — prevents stale task lane appearing // in the new empty session (same pattern as selectChat, which sets ref before its await). currentChatRef.current = null // Clear stale state immediately — don't wait for API to return. resetSessionDerivedState() setMessages([]) setActiveSessionStatus('active') setActivePsaTicketId(null) try { const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: '' }, }) const chatItem: ChatListItem = { id: session.session_id, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) } catch { toast.error('Failed to create chat') } } const handleDeleteChat = async (chatId: string) => { try { await aiSessionsApi.deleteSession(chatId) setChats(prev => prev.filter(c => c.id !== chatId)) if (activeChatId === chatId) { resetSessionDerivedState() setActiveChatId(null) } } catch { toast.error('Failed to delete chat') } } const handleTogglePin = async () => { // Pin/unpin not yet supported on unified sessions — no-op for now toast.info('Pin feature coming soon') } const handleSend = async () => { if (!input.trim() || !activeChatId || loading) return const userMessage = input.trim() const completedUploads = pendingUploads.filter((u) => u.status === 'done' && u.result?.id) const completedUploadIds = completedUploads.map((u) => u.result!.id) const imageUrls = completedUploads .filter((u) => u.preview) .map((u) => u.preview) setInput('') setPendingUploads([]) setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }]) setLoading(true) const sentForChatId = activeChatId try { const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage, upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, }) // Guard: discard if user switched to a different chat while this was in flight if (!guardCurrentChat(sentForChatId, 'handleSend')) return analytics.aiFeatureUsed({ feature: 'assistant_chat' }) setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, ]) setChats(prev => prev.map(c => c.id === sentForChatId ? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() } : c ) ) // Load branches if fork was created if (response.fork && sentForChatId) { branching.loadBranches(sentForChatId) } // Show task lane if AI sent questions or actions const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { clearTaskState(sentForChatId) setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) setTaskLaneOwnerChatId(sentForChatId) } // Phase 8: increment post-apply message counter for nudge logic. // Only increments when fix is still in 'proposed' (verifying) state — // partial/dismissed/terminal states don't render the nudge, and a // partial→verifying transition could inherit an already-saturated counter. if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') { setPostApplyMsgCount(c => c + 1) } // Refetch facts + active fix; preview refreshes if open. refreshSessionDerived(sentForChatId) } catch (err: unknown) { console.error('[AssistantChat] sendChatMessage failed:', err) const status = (err as { response?: { status?: number } })?.response?.status let errorMsg: string if (status === 429) { errorMsg = "You're sending messages too quickly. Wait a moment and try again." } else if (status === 502 || status === 503) { errorMsg = "The AI is temporarily unavailable. Please try again in a few seconds." } else { errorMsg = "Something went wrong sending your message. Please try again." } // Remove the optimistic user message and restore it to the input so they can retry setMessages(prev => prev.slice(0, -1)) setInput(userMessage) toast.error(errorMsg) } finally { setLoading(false) requestAnimationFrame(() => inputRef.current?.focus()) } } const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string; command?: string | null }>) => { if (!activeChatId || loading) return // Handle special action commands that open UI flows instead of sending to AI const spinOffAction = responses.find(r => r.type === 'action' && r.command === 'create_spin_off_ticket') if (spinOffAction) { setSpinOffHint(spinOffAction.label || spinOffAction.text) setShowNewTicket(true) return } // Format task responses into a structured message for the AI. // Pending tasks are included so the AI knows they weren't completed yet. const parts: string[] = [] for (const r of responses) { const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check' if (r.state === 'done' && r.value.trim()) { parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``) } else if (r.state === 'skipped') { parts.push(`**${name}:** _(skipped)_`) } else { parts.push(`**${name}:** _(not yet completed)_`) } } const userMessage = parts.join('\n\n') setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setLoading(true) const sentForChatId = activeChatId try { const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage }) // Guard: discard if user switched to a different chat while this was in flight if (!guardCurrentChat(sentForChatId, 'handleTaskSubmit')) return setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, ]) if (response.fork && sentForChatId) { branching.loadBranches(sentForChatId) } // Update task lane based on AI response const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 clearTaskState(sentForChatId) if (hasQuestions || hasActions) { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) setTaskLaneOwnerChatId(sentForChatId) } else { // AI sent no new tasks — clear the lane setShowTaskLane(false) setActiveQuestions([]) setActiveActions([]) setTaskLaneOwnerChatId(null) } // Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend). // Only increments in 'proposed' (verifying) state — same rationale as handleSend. if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') { setPostApplyMsgCount(c => c + 1) } // Refetch facts + active fix; answering tasks is the primary trigger. refreshSessionDerived(sentForChatId) } catch (err: unknown) { console.error('[AssistantChat] handleTaskSubmit failed:', err) const status = (err as { response?: { status?: number } })?.response?.status const errorMsg = status === 429 ? "You're sending messages too quickly. Wait a moment and try again." : "Something went wrong submitting your responses. Please try again." setMessages(prev => prev.slice(0, -1)) toast.error(errorMsg) } finally { setLoading(false) } } const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { if (!activeChatId) throw new Error('No active chat') if (outcome === 'resolved') { await aiSessionsApi.resolveSession(activeChatId, { resolution_summary: _notes || 'Resolved via assistant chat', }) setActiveSessionStatus('resolved') return activeChatId } else if (outcome === 'escalated') { await aiSessionsApi.escalateSession(activeChatId, { escalation_reason: _notes || 'Escalated from assistant chat', }) setActiveSessionStatus('escalated') return activeChatId } else { await aiSessionsApi.pauseSession(activeChatId) setActiveSessionStatus('paused') return activeChatId } } const handleResumeNew = async (summary: string) => { // Invalidate currentChatRef BEFORE the await — same guard as handleNewChat currentChatRef.current = null // Clear stale state immediately — don't wait for API to return. resetSessionDerivedState() setActiveSessionStatus('active') setActivePsaTicketId(null) try { const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.` const session = await aiSessionsApi.createChatSession({ intake_type: 'free_text', intake_content: { text: resumePrompt }, }) const chatItem: ChatListItem = { id: session.session_id, title: session.title, message_count: 0, pinned: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([{ role: 'user', content: resumePrompt }]) setLoading(true) const response = await aiSessionsApi.sendChatMessage(session.session_id, { message: resumePrompt }) // Guard: discard if user switched to a different chat while this was in flight if (!guardCurrentChat(session.session_id, 'handleResumeNew')) return setMessages(prev => [ ...prev, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, ]) setChats(prev => prev.map(c => c.id === session.session_id ? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() } : c ) ) // Show task lane if AI sent questions or actions if (response.fork && session.session_id) { branching.loadBranches(session.session_id) } const hasQuestions = response.questions && response.questions.length > 0 const hasActions = response.actions && response.actions.length > 0 if (hasQuestions || hasActions) { setActiveQuestions(response.questions || []) setActiveActions(response.actions || []) setShowTaskLane(true) setTaskLaneOwnerChatId(session.session_id) } // Refetch facts + active fix — resume turn may emit markers. refreshSessionDerived(session.session_id) } catch { toast.error('Failed to create resume chat') } finally { setLoading(false) } } const handleKeyDown = (e: React.KeyboardEvent) => { // ⌘↵ / Ctrl+↵ is the FlowPilot-wide "send" shortcut (Phase 7 spec). // Plain Enter (without modifiers, without shift) also sends to preserve // existing composer ergonomics. Shift+Enter keeps the newline behavior. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault() handleSend() return } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSend() } } // Auto-grow textarea useEffect(() => { const el = inputRef.current if (!el) return el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 150)}px` }, [input]) // Phase 7: global keyboard shortcuts. The hook auto-skips keypresses that // originate inside inputs/textareas, so `?` and ⌘G won't fight the composer. // ⌘K is handled by the TopBar (opens the global command palette). useKeyboardShortcuts([ { key: '?', shift: true, handler: () => setShortcutsHelpOpen((v) => !v), }, { key: 'g', ctrl: true, handler: () => { if (activeFix) setScriptPanelOpen((v) => !v) }, enabled: activeFix !== null, }, ]) // ── File handling ────────────────────────────── const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx' const processFiles = useCallback((files: File[]) => { if (files.length === 0) return const newUploads: PendingUpload[] = files.map((file) => ({ id: crypto.randomUUID(), file, preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '', status: 'uploading' as const, })) setPendingUploads((prev) => [...prev, ...newUploads]) newUploads.forEach((upload) => { uploadsApi.upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u)) }) .catch((err) => { const is503 = err?.response?.status === 503 if (is503) { toast.warning('Image attachments are not available yet — describe the issue in text instead') } else { toast.error(`Upload failed: ${err?.message || 'Unknown error'}`) } setPendingUploads((prev) => prev.filter((u) => u.id !== upload.id)) }) }) }, []) const handlePaste = useCallback((e: React.ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return const imageFiles: File[] = [] for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile() if (file) imageFiles.push(file) } } if (imageFiles.length > 0) { e.preventDefault() processFiles(imageFiles) } }, [processFiles]) const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }, []) const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setIsDragOver(true) }, []) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setIsDragOver(false) }, []) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setIsDragOver(false); processFiles(Array.from(e.dataTransfer.files)) }, [processFiles]) const handleFileSelect = useCallback((e: React.ChangeEvent) => { if (e.target.files) { processFiles(Array.from(e.target.files)); e.target.value = '' } }, [processFiles]) const handleRemoveUpload = useCallback((uploadId: string) => { setPendingUploads((prev) => { const toRemove = prev.find((u) => u.id === uploadId); if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview); return prev.filter((u) => u.id !== uploadId) }) }, []) const retryUpload = useCallback((uploadId: string) => { const upload = pendingUploads.find((u) => u.id === uploadId) if (!upload) return setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u)) uploadsApi.upload(upload.file) .then((result) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)) }) .catch((err) => { setPendingUploads((prev) => prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)) }) }, [pendingUploads]) // 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 ( <>
0 || activeQuestions.length > 0} activeOptionKey={activeOptionKey} />
) } return ( <>
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */} {!sidebarCollapsed && (
)}
setMobileSidebarOpen(false)} />
{/* Main chat area + optional branch sidebar */}
{/* Collapsed sidebar top bar — desktop only */} {sidebarCollapsed && (
)} {/* Chat content row: chat column + TaskLane side by side */}
{/* Mobile header with chat history toggle */}
{activeChatId ? ( <> {/* Session header — title + lifecycle actions */} {(() => { const chatTitle = chats.find(c => c.id === activeChatId)?.title const isActive = activeSessionStatus === 'active' || activeSessionStatus === null const canAct = messages.length >= 2 && isActive && !loading const updateLabel = activePsaTicketId ? 'Update Ticket' : 'Share Update' return (

{chatTitle || 'AI Assistant'}

{activeSessionStatus && activeSessionStatus !== 'active' && ( {activeSessionStatus === 'requesting_escalation' ? 'Escalated' : activeSessionStatus.charAt(0).toUpperCase() + activeSessionStatus.slice(1)} )}
{/* Desktop actions — Resolve + Escalate stay first-class; everything else (Context / New Ticket / Update Ticket / Pause) folds behind a single kebab to keep the header to two visible primary actions. */}
{isActive && ( <>
{escalateIntercept && ( setEscalateIntercept(null)} /> )}
)} {(magicHandoff || activePsaTicketId || messages.length >= 2) && (
{showOverflow && ( <>
setShowOverflow(false)} />
{magicHandoff && ( )} {activePsaTicketId && ( )} {messages.length >= 2 && ( )} {isActive && messages.length >= 2 && ( )}
)}
)}
{/* Mobile: single overflow menu — same items as desktop kebab plus Resolve/Escalate (which live in the visible row on desktop). */} {(magicHandoff || activePsaTicketId || messages.length >= 2) && (
{showOverflow && ( <>
setShowOverflow(false)} />
{isActive && messages.length >= 2 && ( <> {/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
{/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */} {escalateIntercept && ( setEscalateIntercept(null)} /> )}
)} {magicHandoff && ( )} {activePsaTicketId && ( )} {messages.length >= 2 && ( )} {isActive && messages.length >= 2 && ( )}
)}
)}
) })()} {/* Phase 9: ChatTabStrip — shown when the fix needs a script drafted */} {showTabStrip && ( )} {/* Chat tab content — messages + banner + composer. Hidden (not unmounted) when Script Builder tab is active so scroll position and input state are preserved. */}
{/* Messages — scroll container is full width (so the scrollbar lives at the chat-column edge) but content is centered to max-w-3xl to match the composer below, giving the column a single anchor. */}
{messages.length === 0 && !loading && (

AI Assistant

Ask me anything about IT infrastructure, networking, Active Directory, cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.

)} {(() => { // Action emphasis is shown on the *current* turn only — i.e. the // latest assistant message when active items are pending and the // magic-moment hero has dismissed. The TaskLane remains the // canonical list; this is just an inline cue. let lastAssistantIdx = -1 for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'assistant') { lastAssistantIdx = i; break } } const showActionEmphasis = magicState === 'dismissed' && (activeQuestions.length + activeActions.length) > 0 const turnActionCount = activeQuestions.length + activeActions.length return messages.map((msg, i) => ( )) })()} {loading && (
)}
{/* Phase 8: ProposalBanner — mounted above the composer */} {activeFix && bannerMode && ( setBannerCollapsed(v => !v)} onApply={handleApplyFix} onDismiss={() => handleSetOutcome('dismissed')} onOutcome={handleSetOutcome} onAcceptAIProposal={handleAcceptAIProposal} onRejectAIProposal={handleRejectAIProposal} onSilenceNudge={handleSilenceNudge} /> )} {/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case, rendered in the chat region above the composer so all three option cards fit side-by-side without the TaskLane's narrow width. Hidden when the banner is collapsed: the two surfaces are linked. */} {scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && ( setScriptPanelOpen(false)} onDecide={handleScriptDecision} busy={scriptDecisionBusy} /> )} {/* Rich Input */}
{/* Drag overlay */} {isDragOver && (
Drop files to attach
)} {/* Textarea */}