Files
resolutionflow/frontend/src/pages/AssistantChatPage.tsx
Michael Chihlas d3a9031e23
All checks were successful
Mirror to GitHub / mirror (push) Successful in 12s
CI / frontend (pull_request) Successful in 5m33s
CI / backend (pull_request) Successful in 10m57s
CI / e2e (pull_request) Successful in 13m21s
chore(session): bump keyboard hint contrast + drop redundant font-sans
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>
2026-05-01 16:50:09 -04:00

2493 lines
114 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
? 'Couldnt record the partial outcome. Add a short note and try again.'
: 'Couldnt 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>
</>
)
}