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/instrument.ts b/frontend/src/instrument.ts index 99c59e92..87ae697c 100644 --- a/frontend/src/instrument.ts +++ b/frontend/src/instrument.ts @@ -1,7 +1,7 @@ import * as Sentry from "@sentry/react"; Sentry.init({ - dsn: import.meta.env.VITE_SENTRY_DSN || "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328", + dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, integrations: [ diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 530236d7..3c47835d 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' -import { BrandingSettings } from '@/components/settings/BrandingSettings' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal' @@ -22,7 +21,6 @@ export function AccountSettingsPage() { const { isAccountOwner } = usePermissions() const { plan, limits, usage } = useSubscription() const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore() - const user = useAuthStore((s) => s.user) const subscription = useAuthStore((s) => s.subscription) const [account, setAccount] = useState(null) @@ -45,8 +43,6 @@ export function AccountSettingsPage() { const [inviteEmail, setInviteEmail] = useState('') const [inviteRole, setInviteRole] = useState('engineer') const [isInviting, setIsInviting] = useState(false) - const [inviteError, setInviteError] = useState(null) - const [inviteSuccess, setInviteSuccess] = useState(null) useEffect(() => { loadData() @@ -86,7 +82,9 @@ export function AccountSettingsPage() { const updated = await accountsApi.updateMyAccount({ name: editedName.trim() }) setAccount(updated) setIsEditingName(false) + toast.success('Account name updated') } catch (err) { + toast.error('Failed to update account name') console.error('Failed to update account name:', err) } finally { setIsSavingName(false) @@ -98,17 +96,14 @@ export function AccountSettingsPage() { if (!inviteEmail.trim()) return setIsInviting(true) - setInviteError(null) - setInviteSuccess(null) try { await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole }) - setInviteSuccess(`Invitation sent to ${inviteEmail}`) + toast.success(`Invitation sent to ${inviteEmail}`) setInviteEmail('') - // Refresh invites list const invitesData = await accountsApi.getInvites() setInvites(invitesData) } catch (err) { - setInviteError('Failed to send invitation') + toast.error('Failed to send invitation') console.error(err) } finally { setIsInviting(false) @@ -135,7 +130,9 @@ export function AccountSettingsPage() { try { await accountsApi.removeMember(userId) setMembers(members.filter((m) => m.id !== userId)) + toast.success('Member removed') } catch (err) { + toast.error('Failed to remove member') console.error('Failed to remove member:', err) } } @@ -150,7 +147,7 @@ export function AccountSettingsPage() { if (error) { return ( -
+
{error} @@ -230,7 +227,7 @@ export function AccountSettingsPage() { {isAccountOwner && ( @@ -261,9 +258,9 @@ export function AccountSettingsPage() { @@ -273,11 +270,11 @@ export function AccountSettingsPage() { {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} @@ -295,7 +292,7 @@ export function AccountSettingsPage() { {limits && usage && (
@@ -350,12 +347,13 @@ export function AccountSettingsPage() {
{member.account_role === 'owner' ? ( - + owner ) : ( )} {!member.is_active && ( - + Inactive )} {member.account_role !== 'owner' && ( @@ -438,12 +436,6 @@ export function AccountSettingsPage() {
- {inviteError && ( -

{inviteError}

- )} - {inviteSuccess && ( -

{inviteSuccess}

- )} {/* Pending Invites */} @@ -467,14 +459,14 @@ export function AccountSettingsPage() {

- + {invite.role} @@ -774,7 +755,7 @@ function UsageStat({

{current} @@ -783,11 +764,11 @@ function UsageStat({

{!isUnlimited && ( -
+
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 }])