Three improvements driven by live wedge testing.
1) Notification title now includes a problem snippet and PSA ticket
suffix when present:
"Escalation from Jane · #12345: Outlook is failing to sync email…"
Replaces the prior "Session escalated by Jane" copy that made every
escalation from the same junior look identical in the bell panel.
Snippet is trimmed to 70 chars with ellipsis. handoff_manager now
passes psa_ticket_id through in the notify() payload so this works
for both /escalate and /handoff entry points.
2) AI enrichment (assessment + enhanced escalation_package) moved to
a FastAPI BackgroundTask. The escalating engineer no longer waits
on 15-25s of Sonnet latency — handoff creation returns as soon as
snapshot, status flip, dual-write, documentation, PSA push, and
notify() are committed. enrich_escalation_async opens its own DB
session, runs both AI calls, updates handoff.ai_assessment +
session.escalation_package, commits, and publishes a new
`handoff_assessment_ready` event on the escalation bus. Frontend
doesn't yet listen for that event — the magic-moment screen still
shows a placeholder ("AI assessment is still generating. Reopen
this view in a few seconds…") which is honest about the state.
Live polling / auto-refresh on the bus event is the natural next
step.
3) ChatSidebar entries now surface the problem summary as a secondary
line and tag PSA-linked sessions with a monospace #ticket badge plus
an "Escalated" pill on in-transit sessions. ChatListItem grew
problem_summary, psa_ticket_id, and status fields; loadChats
populates them from listSessions. The user couldn't tell their own
sessions apart in the sidebar because they all rendered as "New
Chat" with no distinguishing detail — this fixes that for any
session, escalated or not.
Test plan
- Backend full suite: 1103 passed in 255.85s with -n auto.
- Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2125 lines
95 KiB
TypeScript
2125 lines
95 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||
import { handoffsApi } from '@/api/handoffs'
|
||
import type { HandoffResponse } from '@/types/branching'
|
||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { uploadsApi } from '@/api/uploads'
|
||
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 [claiming, setClaiming] = useState(false)
|
||
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)
|
||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||
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[]>(() => {
|
||
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(() => {
|
||
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
|
||
})
|
||
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_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)
|
||
|
||
// 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.
|
||
useEffect(() => {
|
||
if (!urlSessionId || urlSessionId === activeChatId) return
|
||
if (magicState === 'loading' || magicState === 'visible') return
|
||
selectChat(urlSessionId)
|
||
}, [urlSessionId, magicState]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Pickup mode entry: fetch the handoff list (account-scoped via RLS, no
|
||
// claim required) to find the latest unclaimed escalate handoff. If found,
|
||
// render the magic-moment screen. If none found (legacy sessions
|
||
// pre-unification, or the handoff was already claimed by another senior),
|
||
// dismiss and let the regular chat surface load.
|
||
useEffect(() => {
|
||
if (!isPickup || !urlSessionId || magicState !== 'loading') return
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const handoffs = await handoffsApi.listHandoffs(urlSessionId)
|
||
if (cancelled) return
|
||
const target = handoffs.find(h => h.intent === 'escalate' && !h.claimed_by)
|
||
if (target) {
|
||
setMagicHandoff(target)
|
||
setMagicState('visible')
|
||
} else {
|
||
setMagicState('dismissed')
|
||
// Strip ?pickup=true so a refresh doesn't re-enter the loading
|
||
// state needlessly.
|
||
setSearchParams({})
|
||
}
|
||
} catch {
|
||
if (cancelled) return
|
||
setMagicState('dismissed')
|
||
setSearchParams({})
|
||
}
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [isPickup, urlSessionId, magicState, setSearchParams])
|
||
|
||
const handleStartHere = useCallback(async () => {
|
||
if (!urlSessionId || !magicHandoff) return
|
||
setClaiming(true)
|
||
try {
|
||
await handoffsApi.claimHandoff(urlSessionId, magicHandoff.id)
|
||
// Drop ?pickup=true and dismiss the magic-moment. The session-load
|
||
// effect above will then fire because magicState !== 'loading'/'visible'
|
||
// and selectChat will populate the chat surface — the senior is now
|
||
// escalated_to_id, so GET succeeds and the conversation_messages render
|
||
// as chat history.
|
||
setSearchParams({})
|
||
setMagicState('dismissed')
|
||
} catch (e: unknown) {
|
||
const message = e instanceof Error ? e.message : 'Failed to pick up session'
|
||
toast.error(message)
|
||
} finally {
|
||
setClaiming(false)
|
||
}
|
||
}, [urlSessionId, magicHandoff, setSearchParams])
|
||
|
||
const openHandoffContextOverlay = useCallback(async () => {
|
||
if (!activeChatId) return
|
||
if (magicHandoff) {
|
||
setOverlayHandoff(magicHandoff)
|
||
return
|
||
}
|
||
setOverlayLoading(true)
|
||
try {
|
||
const handoffs = await handoffsApi.listHandoffs(activeChatId)
|
||
const target = handoffs.find(h => h.intent === 'escalate')
|
||
if (target) {
|
||
setOverlayHandoff(target)
|
||
} else {
|
||
toast.info('No handoff context available for this session.')
|
||
}
|
||
} catch {
|
||
toast.error('Could not load handoff context')
|
||
} finally {
|
||
setOverlayLoading(false)
|
||
}
|
||
}, [activeChatId, magicHandoff])
|
||
|
||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||
useEffect(() => {
|
||
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)
|
||
}
|
||
// 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
|
||
}, [])
|
||
|
||
// Persist task lane metadata to sessionStorage
|
||
useEffect(() => {
|
||
try {
|
||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||
show: showTaskLane,
|
||
chatId: activeChatId,
|
||
questions: activeQuestions,
|
||
actions: activeActions,
|
||
}))
|
||
} catch { /* ignore */ }
|
||
}, [showTaskLane, activeChatId, 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])
|
||
|
||
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([])
|
||
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)
|
||
}, [])
|
||
|
||
// 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 (currentChatRef.current !== chatId) 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.
|
||
if (list.length > 0) setShowTaskLane(true)
|
||
} catch {
|
||
// Best-effort — facts are accessory state. Surfacing a toast on every
|
||
// refetch failure would be noisy; the empty state explains the absence.
|
||
}
|
||
}, [])
|
||
|
||
// 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 (currentChatRef.current !== chatId) 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.
|
||
}
|
||
}, [])
|
||
|
||
// 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 (currentChatRef.current !== chatId) 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)
|
||
}
|
||
}, [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.
|
||
setShowTaskLane(true)
|
||
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])
|
||
|
||
// 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)
|
||
// 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'
|
||
)
|
||
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 state
|
||
// before opening the resolution note preview.
|
||
const handleResolveClick = useCallback(async () => {
|
||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
|
||
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 (currentChatRef.current !== chatId) return
|
||
setActiveSessionStatus(detail.status)
|
||
setActivePsaTicketId(detail.psa_ticket_id)
|
||
if (detail.psa_ticket_id) {
|
||
integrationsApi.getTicket(detail.psa_ticket_id)
|
||
.then(ticket => {
|
||
if (currentChatRef.current !== chatId) 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)
|
||
}
|
||
}
|
||
} catch {
|
||
setMessages([])
|
||
}
|
||
}, [refreshSessionDerived])
|
||
|
||
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 (currentChatRef.current !== sentForChatId) 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)
|
||
}
|
||
// 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 (currentChatRef.current !== sentForChatId) 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)
|
||
} else {
|
||
// AI sent no new tasks — clear the lane
|
||
setShowTaskLane(false)
|
||
setActiveQuestions([])
|
||
setActiveActions([])
|
||
}
|
||
// 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 (currentChatRef.current !== session.session_id) 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)
|
||
}
|
||
// 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}
|
||
onStartHere={handleStartHere}
|
||
isProcessing={claiming}
|
||
/>
|
||
</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 — shown when session is active and has messages */}
|
||
<div className="hidden sm:flex items-center gap-1.5">
|
||
{magicHandoff && (
|
||
<button
|
||
onClick={openHandoffContextOverlay}
|
||
disabled={overlayLoading}
|
||
title="Show the handoff context the original engineer sent"
|
||
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
|
||
>
|
||
<Sparkles size={13} />
|
||
Context
|
||
</button>
|
||
)}
|
||
{activePsaTicketId && (
|
||
<button
|
||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
|
||
>
|
||
<Plus className="w-3 h-3" /> New Ticket
|
||
</button>
|
||
)}
|
||
{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>
|
||
</>
|
||
)}
|
||
{messages.length >= 2 && (
|
||
<button
|
||
onClick={() => setShowStatusUpdate(true)}
|
||
disabled={loading}
|
||
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||
>
|
||
<FileText size={13} />
|
||
{updateLabel}
|
||
</button>
|
||
)}
|
||
{/* Overflow: Pause / — */}
|
||
{isActive && 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"
|
||
>
|
||
<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-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||
<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 */}
|
||
{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"
|
||
>
|
||
<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 && (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
<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 && (
|
||
<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 */}
|
||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 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>
|
||
)}
|
||
{messages.map((msg, i) => (
|
||
<ChatMessage
|
||
key={i}
|
||
role={msg.role}
|
||
content={msg.content}
|
||
suggestedFlows={msg.suggestedFlows}
|
||
imageUrls={msg.imageUrls}
|
||
/>
|
||
))}
|
||
{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-2xl px-4 py-3">
|
||
<Loader2 size={16} className="animate-spin text-primary" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</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. */}
|
||
{scriptPanelOpen && 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.5rem] 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 font-sans">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 && (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
|
||
&& (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 && (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
|
||
facts={facts}
|
||
onAddNote={handleAddNote}
|
||
onUpdateFact={handleUpdateFact}
|
||
onDeleteFact={handleDeleteFact}
|
||
loading={loading}
|
||
/>
|
||
}
|
||
bottomSlot={
|
||
<>
|
||
{scriptPanelOpen && 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-[0.75rem] 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-[0.75rem] 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
|
||
facts={facts}
|
||
onAddNote={handleAddNote}
|
||
onUpdateFact={handleUpdateFact}
|
||
onDeleteFact={handleDeleteFact}
|
||
loading={loading}
|
||
/>
|
||
}
|
||
bottomSlot={
|
||
<>
|
||
{scriptPanelOpen && 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-[0.75rem] 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-[0.75rem] 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)}
|
||
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/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||
}}
|
||
>
|
||
<HandoffContextScreen
|
||
handoff={overlayHandoff}
|
||
onStartHere={() => {}}
|
||
onDismiss={() => setOverlayHandoff(null)}
|
||
dismissible
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)
|
||
}
|