fix: resolve race conditions in assistant/cockpit session loading

- Always load session data on mount even when urlSessionId matches
  activeChatId, fixing empty state after view toggle between /assistant
  and /cockpit (tasks/messages not showing until sidebar click)
- Add loadingRef for synchronous guards preventing duplicate sends,
  duplicate session creation, and prefill races
- Fix stale evidence_items closure in CockpitPage handlers
- Move setLoading(true) before first await in handlePrefill and
  handleResumeNew

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-03 02:49:29 +00:00
parent 3ea669a1e5
commit 4ba32a08ac
3 changed files with 43 additions and 21 deletions

View File

@@ -73,6 +73,8 @@ export function useAssistantSession() {
const dragCounterRef = useRef(0)
const prefillHandledRef = useRef(false)
const currentChatRef = useRef<string | null>(activeChatId)
const loadingRef = useRef(false)
const initialLoadDoneRef = useRef(false)
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
@@ -91,20 +93,22 @@ export function useAssistantSession() {
// Load chat list on mount
useEffect(() => { loadChats() }, [])
// If URL has a session ID, load it
// Load session data on mount or when URL session changes.
// On initial mount, always load even if activeChatId matches urlSessionId
// (state is empty after view toggle between /assistant and /cockpit).
useEffect(() => {
if (urlSessionId && urlSessionId !== activeChatId) {
selectChat(urlSessionId)
if (urlSessionId) {
if (!initialLoadDoneRef.current || urlSessionId !== activeChatId) {
selectChat(urlSessionId)
}
initialLoadDoneRef.current = true
} else if (!initialLoadDoneRef.current && activeChatId) {
// Restore session from sessionStorage on mount (when URL has no session ID)
selectChat(activeChatId)
initialLoadDoneRef.current = true
}
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// 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
// Persist task lane metadata to sessionStorage
useEffect(() => {
try {
@@ -206,6 +210,8 @@ export function useAssistantSession() {
}, [])
const handleNewChat = async () => {
if (loadingRef.current) return
loadingRef.current = true
try {
const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text',
@@ -228,6 +234,8 @@ export function useAssistantSession() {
setActiveActions([])
} catch {
toast.error('Failed to create chat')
} finally {
loadingRef.current = false
}
}
@@ -280,7 +288,8 @@ export function useAssistantSession() {
const onTriageUpdateRef = useRef<((update: TriageUpdate) => void) | null>(null)
const handleSend = async () => {
if (!input.trim() || !activeChatId || loading) return
if (!input.trim() || !activeChatId || loadingRef.current) return
loadingRef.current = true
const sendChatId = activeChatId
const userMessage = input.trim()
@@ -315,6 +324,7 @@ export function useAssistantSession() {
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
])
} finally {
loadingRef.current = false
if (currentChatRef.current === sendChatId) {
setLoading(false)
requestAnimationFrame(() => inputRef.current?.focus())
@@ -333,9 +343,12 @@ export function useAssistantSession() {
navigate(location.pathname, { replace: true, state: {} })
const sendPrefill = async () => {
if (loadingRef.current) return
loadingRef.current = true
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setLoading(true)
try {
const session = await aiSessionsApi.createChatSession({
@@ -355,7 +368,6 @@ export function useAssistantSession() {
setChats(prev => [chatItem, ...prev])
setActiveChatId(prefillChatId)
setMessages([{ role: 'user', content: prefill }])
setLoading(true)
const response = await aiSessionsApi.sendChatMessage(prefillChatId, {
message: prefill,
@@ -374,6 +386,7 @@ export function useAssistantSession() {
} catch {
toast.error('Failed to start AI conversation')
} finally {
loadingRef.current = false
setLoading(false)
}
}
@@ -401,6 +414,9 @@ export function useAssistantSession() {
}
const handleResumeNew = async (summary: string) => {
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
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({
@@ -419,7 +435,6 @@ export function useAssistantSession() {
setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id)
setMessages([{ role: 'user', content: resumePrompt }])
setLoading(true)
const resumeChatId = session.session_id
const response = await aiSessionsApi.sendChatMessage(resumeChatId, { message: resumePrompt })
@@ -436,6 +451,7 @@ export function useAssistantSession() {
} catch {
toast.error('Failed to create resume chat')
} finally {
loadingRef.current = false
setLoading(false)
}
}
@@ -528,7 +544,7 @@ export function useAssistantSession() {
handleFileSelect, handleRemoveUpload, retryUpload,
toggleSidebarCollapse, handlePrefill, processResponse,
// Refs
messagesEndRef, inputRef, fileInputRef, currentChatRef,
messagesEndRef, inputRef, fileInputRef, currentChatRef, loadingRef,
// Page-specific callbacks
onSessionLoadedRef, onTriageUpdateRef,
// Constants

View File

@@ -99,20 +99,26 @@ export default function CockpitPage() {
const handleEvidenceAdd = useCallback(async (text: string, status: EvidenceItem['status']) => {
const newItem: EvidenceItem = { text, status }
const updated = [...triageMeta.evidence_items, newItem]
setTriageMeta(prev => ({ ...prev, evidence_items: updated }))
let updated: EvidenceItem[] = []
setTriageMeta(prev => {
updated = [...prev.evidence_items, newItem]
return { ...prev, evidence_items: updated }
})
if (session.activeChatId) {
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
}
}, [session.activeChatId, triageMeta.evidence_items])
}, [session.activeChatId])
const handleEvidenceEdit = useCallback(async (index: number, text: string, status: EvidenceItem['status']) => {
const updated = triageMeta.evidence_items.map((item, i) => i === index ? { text, status } : item)
setTriageMeta(prev => ({ ...prev, evidence_items: updated }))
let updated: EvidenceItem[] = []
setTriageMeta(prev => {
updated = prev.evidence_items.map((item, i) => i === index ? { text, status } : item)
return { ...prev, evidence_items: updated }
})
if (session.activeChatId) {
try { await aiSessionsApi.updateTriage(session.activeChatId, { evidence_items: updated }) } catch { /* best-effort */ }
}
}, [session.activeChatId, triageMeta.evidence_items])
}, [session.activeChatId])
const handleStepComplete = useCallback((index: number) => {
setCompletedSteps(prev => {

View File

@@ -20,7 +20,7 @@ export default function FlowPilotPage() {
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!session.activeChatId || session.loading) return
if (!session.activeChatId || session.loading || session.loadingRef.current) return
const parts: string[] = []
for (const r of responses) {