diff --git a/CLAUDE.md b/CLAUDE.md index 78ab6de5..3ebb8970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,7 +373,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`. ---- +**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`). ## RBAC & Permissions diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index da71faa2..9b6169cb 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -81,6 +81,9 @@ export default function AssistantChatPage() { const fileInputRef = useRef(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(activeChatId) // Persist active chat ID to sessionStorage useEffect(() => { @@ -214,6 +217,7 @@ export default function AssistantChatPage() { } const selectChat = useCallback(async (chatId: string) => { + currentChatRef.current = chatId setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available setShowTaskLane(false) @@ -221,6 +225,10 @@ export default function AssistantChatPage() { setActiveActions([]) try { const detail = await aiSessionsApi.getSession(chatId) + // Guard: if the user switched to a different chat while this API call was + // in flight (e.g. clicked "New Chat"), discard stale results so we don't + // clobber the new session's task lane state. + if (currentChatRef.current !== chatId) return setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', @@ -264,6 +272,7 @@ export default function AssistantChatPage() { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } + currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([]) @@ -430,6 +439,7 @@ export default function AssistantChatPage() { 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 }])