Two small ergonomic fixes after the impeccable pass: - TaskLane keyboard hints (⏎ submit · ⇧⏎ newline) under each open input were rendered at text-muted-foreground/70, just shy of legible at a glance. Drop the /70 opacity modifier so they read at full muted weight on first look without becoming visually loud. - 12 sites across the session screen had explicit font-sans utilities, but the body default is already IBM Plex Sans (via --font-sans in index.css and Tailwind v4's default-sans binding). None of the call sites sit inside a font-heading or font-mono cascade, so every font-sans there was a no-op. Drop them. ConcludeSessionModal also had three "text-xs font-sans text-xs" triplets — drop both the redundant font-sans and the doubled text-xs in one pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2493 lines
114 KiB
TypeScript
2493 lines
114 KiB
TypeScript
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<HandoffResponse | null>(null)
|
||
const [overlayHandoff, setOverlayHandoff] = useState<HandoffResponse | null>(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<ChatListItem[]>([])
|
||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||
if (urlSessionId) return urlSessionId
|
||
try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null }
|
||
})
|
||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||
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<PendingUpload[]>([])
|
||
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<QuestionItem[]>(() => {
|
||
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<ActionItem[]>(() => {
|
||
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<string | null>(() => {
|
||
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<string | null>(null)
|
||
const [activePsaTicketId, setActivePsaTicketId] = useState<string | null>(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<SessionFact[]>([])
|
||
// 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<SessionSuggestedFix | null>(null)
|
||
const [previewKind, setPreviewKind] = useState<'resolve' | 'escalate' | null>(null)
|
||
const [previewData, setPreviewData] = useState<ResolutionNotePreviewData | null>(null)
|
||
const [previewLoading, setPreviewLoading] = useState(false)
|
||
const [previewError, setPreviewError] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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<DraftTemplate[]>([])
|
||
// 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<PSATicketInfo | null>(null)
|
||
const [showNewTicket, setShowNewTicket] = useState(false)
|
||
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(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<ChatTab>('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<HTMLDivElement>(null)
|
||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||
const fileInputRef = useRef<HTMLInputElement>(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<string | null>(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<Set<string>>(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<string, string> },
|
||
) => {
|
||
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<string, unknown>
|
||
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<string> => {
|
||
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<HTMLTextAreaElement>) => {
|
||
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<HTMLInputElement>) => { 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 (
|
||
<>
|
||
<PageMeta title="Picking up session…" />
|
||
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
|
||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
if (magicState === 'visible' && magicHandoff) {
|
||
return (
|
||
<>
|
||
<PageMeta title="Escalation handoff" />
|
||
<div className="h-[calc(100vh-3.5rem)] overflow-y-auto p-4 sm:p-8">
|
||
<HandoffContextScreen
|
||
handoff={magicHandoff}
|
||
onContinue={handleContinue}
|
||
onAIAnalysis={handleAIAnalysis}
|
||
onOwnThing={handleOwnThing}
|
||
isProcessing={activeOptionKey !== null}
|
||
hasTaskLane={activeActions.length > 0 || activeQuestions.length > 0}
|
||
activeOptionKey={activeOptionKey}
|
||
/>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<PageMeta title="AI Assistant" />
|
||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||
{/* Sidebar — hidden on mobile, collapsed to top bar or full sidebar on desktop */}
|
||
{!sidebarCollapsed && (
|
||
<div className="hidden sm:block">
|
||
<ChatSidebar
|
||
chats={chats}
|
||
activeChatId={activeChatId}
|
||
onSelectChat={selectChat}
|
||
onNewChat={handleNewChat}
|
||
onDeleteChat={handleDeleteChat}
|
||
onTogglePin={handleTogglePin}
|
||
onToggleCollapse={toggleSidebarCollapse}
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="sm:hidden">
|
||
<ChatSidebar
|
||
chats={chats}
|
||
activeChatId={activeChatId}
|
||
onSelectChat={selectChat}
|
||
onNewChat={handleNewChat}
|
||
onDeleteChat={handleDeleteChat}
|
||
onTogglePin={handleTogglePin}
|
||
mobileOpen={mobileSidebarOpen}
|
||
onMobileClose={() => setMobileSidebarOpen(false)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Main chat area + optional branch sidebar */}
|
||
<div className="flex-1 flex flex-col min-w-0">
|
||
|
||
{/* Collapsed sidebar top bar — desktop only */}
|
||
{sidebarCollapsed && (
|
||
<div className="hidden sm:block">
|
||
<ChatSidebarCollapsedBar
|
||
chats={chats}
|
||
activeChatId={activeChatId}
|
||
onNewChat={handleNewChat}
|
||
onExpand={toggleSidebarCollapse}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Chat content row: chat column + TaskLane side by side */}
|
||
<div className="flex-1 flex min-w-0 min-h-0">
|
||
<div className="flex-1 flex flex-col min-w-0">
|
||
{/* Mobile header with chat history toggle */}
|
||
<div className="sm:hidden flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||
<button
|
||
onClick={() => setMobileSidebarOpen(true)}
|
||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
|
||
>
|
||
<MessageSquare size={16} />
|
||
Chats
|
||
</button>
|
||
<div className="flex-1" />
|
||
<button
|
||
onClick={handleNewChat}
|
||
className="rounded-lg px-3 py-2 text-sm font-medium text-primary hover:bg-primary/10 transition-colors"
|
||
>
|
||
+ New
|
||
</button>
|
||
</div>
|
||
|
||
{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 (
|
||
<div className="flex items-center gap-3 border-b border-border px-3 sm:px-5 py-2.5 shrink-0">
|
||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-dim shrink-0">
|
||
<Sparkles size={14} className="text-primary" />
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
|
||
{chatTitle || 'AI Assistant'}
|
||
</h1>
|
||
{activeSessionStatus && activeSessionStatus !== 'active' && (
|
||
<span className={cn(
|
||
'text-[0.625rem] font-medium uppercase tracking-wide',
|
||
activeSessionStatus === 'resolved' ? 'text-success' :
|
||
activeSessionStatus === 'escalated' || activeSessionStatus === 'requesting_escalation' ? 'text-warning' :
|
||
activeSessionStatus === 'paused' ? 'text-muted-foreground' : 'text-muted-foreground'
|
||
)}>
|
||
{activeSessionStatus === 'requesting_escalation' ? 'Escalated' : activeSessionStatus.charAt(0).toUpperCase() + activeSessionStatus.slice(1)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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. */}
|
||
<div className="hidden sm:flex items-center gap-1.5">
|
||
{isActive && (
|
||
<>
|
||
<button
|
||
onClick={handleResolveClick}
|
||
disabled={!canAct}
|
||
data-conclude-outcome="resolved"
|
||
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||
>
|
||
<CheckCircle2 size={13} />
|
||
Resolve
|
||
</button>
|
||
<div className="relative">
|
||
<button
|
||
onClick={handleEscalateClick}
|
||
disabled={!canAct}
|
||
data-conclude-outcome="escalated"
|
||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||
>
|
||
<ArrowUpRight size={13} />
|
||
Escalate
|
||
</button>
|
||
{escalateIntercept && (
|
||
<EscalateInterceptDialog
|
||
fixTitle={escalateIntercept.fixTitle}
|
||
onChoose={handleInterceptChoice}
|
||
onClose={() => setEscalateIntercept(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowOverflow(!showOverflow)}
|
||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
aria-label="More session actions"
|
||
>
|
||
<MoreHorizontal size={16} />
|
||
</button>
|
||
{showOverflow && (
|
||
<>
|
||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||
{magicHandoff && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||
disabled={overlayLoading}
|
||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||
>
|
||
<Sparkles size={13} />
|
||
Context
|
||
</button>
|
||
)}
|
||
{activePsaTicketId && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
>
|
||
<Plus size={13} />
|
||
New Ticket
|
||
</button>
|
||
)}
|
||
{messages.length >= 2 && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||
disabled={loading}
|
||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||
>
|
||
<FileText size={13} />
|
||
{updateLabel}
|
||
</button>
|
||
)}
|
||
{isActive && messages.length >= 2 && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
>
|
||
<Pause size={13} />
|
||
Pause
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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) && (
|
||
<div className="sm:hidden relative">
|
||
<button
|
||
onClick={() => setShowOverflow(!showOverflow)}
|
||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
aria-label="Session actions"
|
||
>
|
||
<MoreHorizontal size={18} />
|
||
</button>
|
||
{showOverflow && (
|
||
<>
|
||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||
{isActive && messages.length >= 2 && (
|
||
<>
|
||
<button
|
||
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
||
disabled={!canAct}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
|
||
>
|
||
<CheckCircle2 size={14} />
|
||
Resolve
|
||
</button>
|
||
{/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => { setShowOverflow(false); handleEscalateClick() }}
|
||
disabled={!canAct}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
|
||
>
|
||
<ArrowUpRight size={14} />
|
||
Escalate
|
||
</button>
|
||
{/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */}
|
||
{escalateIntercept && (
|
||
<EscalateInterceptDialog
|
||
fixTitle={escalateIntercept.fixTitle}
|
||
onChoose={handleInterceptChoice}
|
||
onClose={() => setEscalateIntercept(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
{magicHandoff && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||
disabled={overlayLoading}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||
>
|
||
<Sparkles size={14} />
|
||
Context
|
||
</button>
|
||
)}
|
||
{activePsaTicketId && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
>
|
||
<Plus size={14} />
|
||
New Ticket
|
||
</button>
|
||
)}
|
||
{messages.length >= 2 && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||
disabled={loading}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||
>
|
||
<FileText size={14} />
|
||
{updateLabel}
|
||
</button>
|
||
)}
|
||
{isActive && messages.length >= 2 && (
|
||
<button
|
||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||
>
|
||
<Pause size={14} />
|
||
Pause
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Phase 9: ChatTabStrip — shown when the fix needs a script drafted */}
|
||
{showTabStrip && (
|
||
<ChatTabStrip
|
||
active={chatTab}
|
||
onChange={setChatTab}
|
||
scriptBuilderHasProgress={scriptBuilderHasProgress}
|
||
/>
|
||
)}
|
||
|
||
{/* Chat tab content — messages + banner + composer.
|
||
Hidden (not unmounted) when Script Builder tab is active so
|
||
scroll position and input state are preserved. */}
|
||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
||
{/* 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. */}
|
||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
||
<div className="max-w-3xl mx-auto space-y-4">
|
||
{messages.length === 0 && !loading && (
|
||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||
<Sparkles size={28} className="text-primary" />
|
||
</div>
|
||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
||
AI Assistant
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground max-w-md">
|
||
Ask me anything about IT infrastructure, networking, Active Directory,
|
||
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{(() => {
|
||
// 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) => (
|
||
<ChatMessage
|
||
key={i}
|
||
role={msg.role}
|
||
content={msg.content}
|
||
suggestedFlows={msg.suggestedFlows}
|
||
imageUrls={msg.imageUrls}
|
||
actionCount={i === lastAssistantIdx && showActionEmphasis ? turnActionCount : undefined}
|
||
/>
|
||
))
|
||
})()}
|
||
{loading && (
|
||
<div className="flex gap-3">
|
||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||
<Sparkles size={14} className="text-primary" />
|
||
</div>
|
||
<div className="bg-input border border-border rounded-xl px-4 py-3">
|
||
<Loader2 size={16} className="animate-spin text-primary" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
||
{activeFix && bannerMode && (
|
||
<ProposalBanner
|
||
fix={activeFix}
|
||
mode={bannerMode}
|
||
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
|
||
onToggleCollapsed={() => 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 && (
|
||
<InlineNoTemplateDialog
|
||
fix={activeFix}
|
||
onClose={() => setScriptPanelOpen(false)}
|
||
onDecide={handleScriptDecision}
|
||
busy={scriptDecisionBusy}
|
||
/>
|
||
)}
|
||
|
||
{/* Rich Input */}
|
||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||
<div
|
||
className="max-w-3xl mx-auto"
|
||
onDragOver={handleDragOver}
|
||
onDragEnter={handleDragEnter}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<div className={cn(
|
||
'relative rounded-xl border transition-all',
|
||
loading ? 'border-border/50 opacity-50' :
|
||
isDragOver ? 'border-primary/50 bg-primary/5' :
|
||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||
{/* Drag overlay */}
|
||
{isDragOver && (
|
||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
|
||
<div className="flex items-center gap-2 text-sm text-primary">
|
||
<ImagePlus size={18} />
|
||
Drop files to attach
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Textarea */}
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder={loading ? 'AI is thinking...' : 'Type a message, paste a screenshot, or drag a file...'}
|
||
disabled={loading}
|
||
rows={1}
|
||
className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed"
|
||
style={{ minHeight: '40px', maxHeight: '150px' }}
|
||
/>
|
||
|
||
{/* Thumbnail strip */}
|
||
{pendingUploads.length > 0 && (
|
||
<div className="flex gap-2 flex-wrap px-4 pb-1">
|
||
{pendingUploads.map((upload) => (
|
||
<div key={upload.id} className="relative w-12 h-12 rounded-lg overflow-hidden border border-border bg-background">
|
||
{upload.preview ? (
|
||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center text-[0.625rem] text-muted-foreground px-1 text-center">
|
||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||
</div>
|
||
)}
|
||
{upload.status === 'uploading' && (
|
||
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
|
||
<Loader2 size={12} className="animate-spin text-primary" />
|
||
</div>
|
||
)}
|
||
{upload.status === 'done' && (
|
||
<button type="button" onClick={() => handleRemoveUpload(upload.id)} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-danger/20 transition-colors">
|
||
<X size={8} className="text-muted-foreground" />
|
||
</button>
|
||
)}
|
||
{upload.status === 'error' && (
|
||
<div className="absolute inset-0 bg-danger/20 border-2 border-danger flex items-center justify-center cursor-pointer" onClick={() => retryUpload(upload.id)}>
|
||
<RotateCcw size={10} className="text-danger" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Logs textarea */}
|
||
{showLogs && (
|
||
<div className="px-4 pb-1">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground">Paste logs or error output</span>
|
||
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground"><X size={14} /></button>
|
||
</div>
|
||
<textarea
|
||
value={logContent}
|
||
onChange={(e) => setLogContent(e.target.value)}
|
||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||
rows={3}
|
||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom toolbar */}
|
||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border/50">
|
||
<div className="flex items-center gap-0.5">
|
||
<input ref={fileInputRef} type="file" multiple accept={ACCEPTED_FILE_TYPES} onChange={handleFileSelect} className="hidden" />
|
||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Attach files">
|
||
<Paperclip size={14} />
|
||
<span className="hidden sm:inline">Attach</span>
|
||
</button>
|
||
{!showLogs && (
|
||
<button type="button" onClick={() => setShowLogs(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-40" title="Paste logs">
|
||
<Terminal size={14} />
|
||
<span className="hidden sm:inline">Paste Logs</span>
|
||
</button>
|
||
)}
|
||
{!showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowTaskLane(true)}
|
||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||
title="Show task panel"
|
||
>
|
||
<ListChecks size={14} />
|
||
Tasks ({activeQuestions.length + activeActions.length})
|
||
</button>
|
||
)}
|
||
</div>
|
||
<button type="button" onClick={handleSend} disabled={!input.trim() || loading} className={cn(
|
||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
|
||
input.trim() && !loading ? 'bg-primary text-white hover:brightness-110 active:scale-95' : 'bg-secondary text-muted-foreground cursor-not-allowed'
|
||
)} title="Send message">
|
||
<Send size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>{/* end chat-tab content wrapper */}
|
||
|
||
{/* Phase 9: Script Builder tab — mounted alongside chat via display:none
|
||
so both scroll positions and state are preserved across tab switches. */}
|
||
{showTabStrip && activeFix && activeChatId && (
|
||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
|
||
{/* key={activeFix.id} forces a fresh mount when the active
|
||
fix changes within the same pilot session — otherwise
|
||
stale messages / editorBuffer / latestScript from the
|
||
prior fix could submit against the new fix.id. */}
|
||
<ScriptBuilderTab
|
||
key={activeFix.id}
|
||
fix={activeFix}
|
||
pilotSessionId={activeChatId}
|
||
onProgressChange={setScriptBuilderHasProgress}
|
||
onScriptDrafted={(updated) => {
|
||
setActiveFix(updated)
|
||
setChatTab('chat')
|
||
setScriptBuilderHasProgress(false)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||
<div className="w-20 h-20 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||
<Sparkles size={32} className="text-primary" />
|
||
</div>
|
||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||
AI Assistant
|
||
</h2>
|
||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||
or start a new chat to get personalized help with your team's flows.
|
||
</p>
|
||
<button
|
||
onClick={handleNewChat}
|
||
className="bg-primary text-white font-semibold text-sm rounded-lg px-6 py-2.5 hover:brightness-110 active:scale-[0.98] transition-all"
|
||
>
|
||
Start a Conversation
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Phase 7: on narrow viewports (<1200px) the lane is a bottom drawer
|
||
that's hidden by default; a floating "Tasks" button toggles it.
|
||
Shows a count pill when new items are present while closed. */}
|
||
{isNarrow
|
||
&& !showTaskLane
|
||
&& taskLaneIsForActiveChat
|
||
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||
<button
|
||
onClick={() => setShowTaskLane(true)}
|
||
className="fixed bottom-4 right-4 z-40 flex items-center gap-2 rounded-full bg-accent text-white px-4 py-2.5 shadow-lg hover:bg-accent-hover transition-colors"
|
||
title="Open tasks drawer"
|
||
>
|
||
<ListChecks size={14} />
|
||
<span className="text-[0.8125rem] font-semibold">Tasks</span>
|
||
{(activeQuestions.length + activeActions.length) > 0 && (
|
||
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[0.625rem] font-bold tabular-nums">
|
||
{activeQuestions.length + activeActions.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{/* Task lane — slides in when AI sends questions/actions OR when the
|
||
session has any "What we know" facts OR an active suggested fix.
|
||
Phase 2/3 make the lane the structural home of session diagnostic
|
||
state, not a transient questions panel.
|
||
Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
|
||
{showTaskLane && taskLaneIsForActiveChat && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
|
||
isNarrow ? (
|
||
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
|
||
<div
|
||
className="flex-1 bg-black/50"
|
||
onClick={() => setShowTaskLane(false)}
|
||
/>
|
||
<div className="h-[80vh] bg-bg-page">
|
||
<TaskLane
|
||
variant="drawer"
|
||
questions={activeQuestions}
|
||
actions={activeActions}
|
||
sessionId={activeChatId}
|
||
onSubmit={handleTaskSubmit}
|
||
onClose={() => setShowTaskLane(false)}
|
||
loading={loading}
|
||
whatWeKnowSlot={
|
||
<WhatWeKnow
|
||
key={activeChatId ?? 'no-session'}
|
||
sessionId={activeChatId}
|
||
facts={facts}
|
||
onAddNote={handleAddNote}
|
||
onUpdateFact={handleUpdateFact}
|
||
onDeleteFact={handleDeleteFact}
|
||
loading={loading}
|
||
/>
|
||
}
|
||
bottomSlot={
|
||
<>
|
||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||
<TemplateMatchPanel
|
||
fix={activeFix}
|
||
sessionId={activeChatId}
|
||
onClose={() => setScriptPanelOpen(false)}
|
||
onMarkRun={handleMarkRun}
|
||
/>
|
||
)}
|
||
<div className="flex items-center gap-3 px-3 mt-1">
|
||
<button
|
||
onClick={() => handleOpenPreview('resolve')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||
previewKind === 'resolve'
|
||
? 'text-success'
|
||
: 'text-accent-text hover:text-heading',
|
||
)}
|
||
>
|
||
<FileText size={12} />
|
||
{previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
|
||
</button>
|
||
<button
|
||
onClick={() => handleOpenPreview('escalate')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||
previewKind === 'escalate'
|
||
? 'text-warning'
|
||
: 'text-muted-foreground hover:text-heading',
|
||
)}
|
||
>
|
||
<ArrowUpRight size={12} />
|
||
{previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
|
||
</button>
|
||
</div>
|
||
<ResolutionNotePreviewPopover
|
||
kind={previewKind ?? 'resolve'}
|
||
open={previewOpen}
|
||
loading={previewLoading}
|
||
preview={previewData}
|
||
error={previewError}
|
||
onClose={handleClosePreview}
|
||
onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
|
||
onConfirm={handleConfirmPost}
|
||
posting={previewPosting}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<TaskLane
|
||
questions={activeQuestions}
|
||
actions={activeActions}
|
||
sessionId={activeChatId}
|
||
onSubmit={handleTaskSubmit}
|
||
onClose={() => {
|
||
setShowTaskLane(false)
|
||
}}
|
||
loading={loading}
|
||
whatWeKnowSlot={
|
||
<WhatWeKnow
|
||
key={activeChatId ?? 'no-session'}
|
||
sessionId={activeChatId}
|
||
facts={facts}
|
||
onAddNote={handleAddNote}
|
||
onUpdateFact={handleUpdateFact}
|
||
onDeleteFact={handleDeleteFact}
|
||
loading={loading}
|
||
/>
|
||
}
|
||
bottomSlot={
|
||
<>
|
||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||
<TemplateMatchPanel
|
||
fix={activeFix}
|
||
sessionId={activeChatId}
|
||
onClose={() => setScriptPanelOpen(false)}
|
||
onMarkRun={handleMarkRun}
|
||
/>
|
||
)}
|
||
<div className="flex items-center gap-3 px-3 mt-1">
|
||
<button
|
||
onClick={() => handleOpenPreview('resolve')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||
previewKind === 'resolve'
|
||
? 'text-success'
|
||
: 'text-accent-text hover:text-heading',
|
||
)}
|
||
>
|
||
<FileText size={12} />
|
||
{previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
|
||
</button>
|
||
<button
|
||
onClick={() => handleOpenPreview('escalate')}
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||
previewKind === 'escalate'
|
||
? 'text-warning'
|
||
: 'text-muted-foreground hover:text-heading',
|
||
)}
|
||
>
|
||
<ArrowUpRight size={12} />
|
||
{previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
|
||
</button>
|
||
</div>
|
||
<ResolutionNotePreviewPopover
|
||
kind={previewKind ?? 'resolve'}
|
||
open={previewOpen}
|
||
loading={previewLoading}
|
||
preview={previewData}
|
||
error={previewError}
|
||
onClose={handleClosePreview}
|
||
onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
|
||
onConfirm={handleConfirmPost}
|
||
posting={previewPosting}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
)
|
||
)}
|
||
|
||
{/* Branch map hidden — branching is now silent/background only.
|
||
Branches are tracked in the DB but not shown to the user.
|
||
The AI manages branch context internally. */}
|
||
</div>{/* close chat content row */}
|
||
</div>{/* close outer flex-col */}
|
||
|
||
{/* Conclude Session Modal */}
|
||
<ConcludeSessionModal
|
||
isOpen={showConclude}
|
||
onClose={() => {
|
||
setShowConclude(false)
|
||
if (activeSessionStatus === 'escalated') {
|
||
toast.info('Session escalated. Heading back to your dashboard.')
|
||
navigate('/')
|
||
}
|
||
}}
|
||
onConclude={handleConclude}
|
||
onResumeNew={handleResumeNew}
|
||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||
sessionId={activeChatId}
|
||
/>
|
||
|
||
{/* Status Update Modal */}
|
||
{activeChatId && (
|
||
<StatusUpdateModal
|
||
open={showStatusUpdate}
|
||
onClose={() => setShowStatusUpdate(false)}
|
||
onGenerate={(audience, length, context) =>
|
||
aiSessionsApi.generateStatusUpdate(activeChatId, { audience, length, context })
|
||
}
|
||
context="status"
|
||
/>
|
||
)}
|
||
|
||
{/* Phase 6: post-resolve "save as team template?" modal. Shown one draft
|
||
at a time; onResolved advances the queue. */}
|
||
{templatizeQueue.length > 0 && (
|
||
<TemplatizePrompt
|
||
draft={templatizeQueue[0]}
|
||
sourceTicketRef={activePsaTicketId ? `CW #${activePsaTicketId}` : null}
|
||
onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
|
||
/>
|
||
)}
|
||
|
||
{/* Phase 7: keyboard-shortcut help. `?` from anywhere inside /pilot. */}
|
||
<ShortcutsHelpOverlay
|
||
open={shortcutsHelpOpen}
|
||
onClose={() => setShortcutsHelpOpen(false)}
|
||
/>
|
||
|
||
{/* Spin-off Ticket Modal (merged from main) */}
|
||
{showNewTicket && (
|
||
<NewTicketModal
|
||
defaultTab={spinOffHint ? 'quick' : 'manual'}
|
||
summaryHint={spinOffHint}
|
||
initialValues={linkedTicket ? {
|
||
company_id: linkedTicket.company_id,
|
||
board_id: linkedTicket.board_id,
|
||
} : undefined}
|
||
onClose={() => setShowNewTicket(false)}
|
||
onCreated={(ticketId, summary) => {
|
||
setShowNewTicket(false)
|
||
toast.success(`Ticket #${ticketId} created: ${summary}`)
|
||
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Handoff context overlay — re-opened from the toolbar */}
|
||
{overlayHandoff && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8 animate-fade-in"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||
}}
|
||
>
|
||
<HandoffContextScreen
|
||
handoff={overlayHandoff}
|
||
onDismiss={() => setOverlayHandoff(null)}
|
||
dismissible
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|