From ce7c8ac3d56055600ce94fb691c128a13e9421ea Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 22 Apr 2026 01:30:18 -0400 Subject: [PATCH] fix(pilot): wipe full task-lane state on chat switch + extract palette event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from the Phase 5 shakedown: 1. Stale lane data leaking across chats. handleNewChat, sendPrefill, and handleResumeNew were each missed when Phase 3/5 added activeFix, previewKind, previewData, and scriptPanelOpen — only selectChat reset the full set. Result: starting a new chat while a Suggested Fix card was active showed the previous session's fix card (and any open preview/script panel) until the next backend refresh swept it. Consolidated all four entry points behind a single resetSessionDerivedState() helper so adding new lane state in future phases only requires touching one place. 2. CommandPalette TDZ on cold load. SCRIPTS_INLINE_QUICK_ACTION (line 66) referenced PILOT_INLINE_SCRIPT_PATH declared at line 94 — module-level evaluation hit the use before the declaration. Browser blanked with "Cannot access 'PILOT_INLINE_SCRIPT_PATH' before initialization". Moved the path const above its first use; also extracted PILOT_INLINE_SCRIPT_EVENT into a tiny @/lib/pilotEvents module so AssistantChatPage doesn't import the palette component just to read a string — that mixed-export pattern broke Fast Refresh ("consistent components exports") and added an unnecessary import edge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/layout/CommandPalette.tsx | 13 +++-- frontend/src/lib/pilotEvents.ts | 14 ++++++ frontend/src/pages/AssistantChatPage.tsx | 48 ++++++++++--------- 3 files changed, 45 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/pilotEvents.ts diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index ab753b72..d8c1d225 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router-dom' +import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents' import { Search, Loader2, ArrowRight, FileText, Clock, Sparkles, LayoutDashboard, Tag, Plus, BookOpen, Terminal, Zap, @@ -61,7 +62,11 @@ const QUICK_ACTIONS: PaletteItem[] = [ ] // Phase 5: only surfaced when on a /pilot/:id route. Fires the inline-script -// open event instead of navigating away to /scripts. +// open event instead of navigating away to /scripts. The path is a sentinel +// — handleSelect intercepts it and dispatches a window event rather than +// navigating, so the chat page can toggle its inline panel without coupling +// the global palette to chat-page state. +const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__' const SCRIPTS_INLINE_QUICK_ACTION: PaletteItem = { id: 'action-scripts-inline', group: 'quick-actions', @@ -86,12 +91,6 @@ function ItemIcon({ icon, className }: { icon: PaletteItem['icon'], className?: } } -// Phase 5: sentinel path the palette uses to fire the inline-script-generator -// open event instead of navigating. Listened for by AssistantChatPage when -// the user is in an active session. -export const PILOT_INLINE_SCRIPT_EVENT = 'flowpilot:open-inline-script' -const PILOT_INLINE_SCRIPT_PATH = '__pilot_inline_script__' - export function CommandPalette({ open, onClose }: CommandPaletteProps) { const navigate = useNavigate() const location = useLocation() diff --git a/frontend/src/lib/pilotEvents.ts b/frontend/src/lib/pilotEvents.ts new file mode 100644 index 00000000..92c92e50 --- /dev/null +++ b/frontend/src/lib/pilotEvents.ts @@ -0,0 +1,14 @@ +/** + * Cross-component event names for the FlowPilot session UI. + * + * Lives in /lib (not /components) so importing the event name does NOT + * pull in any React component module. AssistantChatPage and CommandPalette + * both reference it without forming an import cycle, and Vite's + * react-fast-refresh "consistent components exports" check stays happy. + */ + +// Phase 5: dispatched by the global Cmd+K palette when the engineer picks +// "Open inline Script Generator" while on a /pilot/:id route. The chat +// page subscribes via `window.addEventListener` and toggles its inline +// Script Generator panel if there's an active suggested fix. +export const PILOT_INLINE_SCRIPT_EVENT = 'flowpilot:open-inline-script' diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 78bbc6b9..4edc528a 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -18,7 +18,7 @@ import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix' import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview' import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel' import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog' -import { PILOT_INLINE_SCRIPT_EVENT } from '@/components/layout/CommandPalette' +import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents' import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts' import { sessionSuggestedFixesApi, @@ -163,9 +163,7 @@ export default function AssistantChatPage() { const sendPrefill = async () => { // Clear stale task lane from previous session - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) + resetSessionDerivedState() setActiveSessionStatus('active') setActivePsaTicketId(null) @@ -274,6 +272,24 @@ export default function AssistantChatPage() { } } + // Single source of truth for "wipe every per-session task-lane state field" + // before switching to a different chat. Called from selectChat, handleNewChat, + // sendPrefill, and handleResumeNew so adding new lane-scoped state in future + // phases only requires touching this one helper. Forgetting to clear a field + // leaks the previous session's data into the new one (Phase 5 regression). + const resetSessionDerivedState = useCallback(() => { + setShowTaskLane(false) + setActiveQuestions([]) + setActiveActions([]) + setFacts([]) + setActiveFix(null) + setPreviewKind(null) + setPreviewData(null) + setPreviewError(null) + setPreviewPosting(false) + setScriptPanelOpen(false) + }, []) + // Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat // and after each chat send, because the AI may have emitted [PROMOTE] markers // that synthesized new facts server-side (see unified_chat_service. @@ -503,17 +519,9 @@ export default function AssistantChatPage() { currentChatRef.current = chatId setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) + resetSessionDerivedState() setActiveSessionStatus(null) setActivePsaTicketId(null) - setFacts([]) - setActiveFix(null) - setPreviewData(null) - setPreviewError(null) - setPreviewKind(null) - setScriptPanelOpen(false) // Fire facts + active-fix fetches in parallel with session detail. refreshSessionDerived(chatId) try { @@ -558,12 +566,8 @@ export default function AssistantChatPage() { // 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 - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) - setFacts([]) - setScriptPanelOpen(false) + // Clear stale state immediately — don't wait for API to return. + resetSessionDerivedState() setMessages([]) setActiveSessionStatus('active') setActivePsaTicketId(null) @@ -763,10 +767,8 @@ export default function AssistantChatPage() { 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 - setShowTaskLane(false) - setActiveQuestions([]) - setActiveActions([]) + // Clear stale state immediately — don't wait for API to return. + resetSessionDerivedState() setActiveSessionStatus('active') setActivePsaTicketId(null) try {