From 6d69b51cdd7a6d734b7ff5b4acc6b85dccf56663 Mon Sep 17 00:00:00 2001 From: marks Date: Thu, 16 Jul 2026 16:21:50 -0500 Subject: [PATCH] Add Chat and Agent mode switch --- frontend/src/components/AgentMode.tsx | 136 ++++++++++++++++-- .../components/AuthenticatedHomeContent.tsx | 35 ++++- frontend/src/components/ProjectDetailView.tsx | 14 +- frontend/src/components/Sidebar.tsx | 100 ++++++++----- frontend/src/components/UnifiedChat.tsx | 16 ++- .../components/WorkspaceModeSwitch.test.tsx | 43 ++++++ .../src/components/WorkspaceModeSwitch.tsx | 112 +++++++++++++++ .../PersistentHomeNavigationContext.ts | 25 +++- .../services/agentSessionSelection.test.ts | 46 ++++++ .../src/services/agentSessionSelection.ts | 30 ++++ frontend/src/services/flags.test.ts | 26 ++++ frontend/src/services/flags.ts | 13 ++ 12 files changed, 530 insertions(+), 66 deletions(-) create mode 100644 frontend/src/components/WorkspaceModeSwitch.test.tsx create mode 100644 frontend/src/components/WorkspaceModeSwitch.tsx create mode 100644 frontend/src/services/agentSessionSelection.test.ts create mode 100644 frontend/src/services/agentSessionSelection.ts diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index c67d81ea..a187b149 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -122,6 +122,10 @@ import { } from "@/utils/utils"; import { isTauriDesktop } from "@/utils/platform"; import { useLocalState } from "@/state/useLocalState"; +import { + usePersistentHomeNavigation, + usePersistentSidebarState +} from "@/contexts/PersistentHomeNavigationContext"; import type { ModelAccessTier, OpenSecretModel, @@ -137,6 +141,9 @@ const MAX_STABLE_SESSION_LOAD_ATTEMPTS = 3; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 100; const SIDEBAR_REORDER_ANIMATION_MS = 150; const SIDEBAR_ICON_STROKE = 2; +const AGENT_SESSION_DELETED_EVENT = "maple:agent-session-deleted"; +// Mode switches remount AgentMode, so project-root mutations must be ordered outside it. +const projectRootPersistenceQueues = new Map>(); const AGENT_SIDEBAR_ELLIPSIS_FADE = "pointer-events-none w-4 shrink-0 self-stretch bg-gradient-to-r from-transparent to-[hsl(var(--muted))] dark:to-[hsl(var(--sidebar))]"; const AGENT_SIDEBAR_ELLIPSIS_TRIGGER_ROW_BASE = @@ -248,10 +255,11 @@ export function AgentMode({ userId }: { userId: string }) { const os = useOpenSecret(); const { createApiKey, deleteApiKey } = os; const { availableModels, modelAliases } = useLocalState(); + const { agentSessionSelection } = usePersistentHomeNavigation(); const isMobile = useIsMobile(); const isLandscapeMobile = useIsLandscapeMobile(); const isCompactLayout = isMobile || isLandscapeMobile; - const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); + const [isSidebarOpen, setIsSidebarOpen] = usePersistentSidebarState(isCompactLayout); const [runtimeStatus, setRuntimeStatus] = useState(null); const [projectOrderState, dispatchProjectOrder] = useReducer( projectOrderReducer, @@ -259,6 +267,7 @@ export function AgentMode({ userId }: { userId: string }) { ); const recentRoots = projectOrderState.visible; const [sessions, setSessions] = useState([]); + const [isSessionHistoryReady, setIsSessionHistoryReady] = useState(false); const [sessionToDelete, setSessionToDelete] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [projectRoot, setProjectRoot] = useState(""); @@ -295,7 +304,6 @@ export function AgentMode({ userId }: { userId: string }) { const activeSessionIdRef = useRef(activeSessionId); const deletedSessionIdsRef = useRef(new Set()); const shouldAutoScrollRef = useRef(true); - const projectRootPersistenceRef = useRef>(Promise.resolve()); const permissionModeUpdateRef = useRef>(Promise.resolve()); const permissionModeUpdateGenerationRef = useRef(0); const selectedModeRef = useRef(mode); @@ -314,6 +322,9 @@ export function AgentMode({ userId }: { userId: string }) { const isAgentModelLockedRef = useRef(false); const mcpSessionLoadGenerationRef = useRef(0); const mcpToggleGenerationRef = useRef(0); + const previousIsCompactLayoutRef = useRef(isCompactLayout); + const hasAttemptedSessionRestoreRef = useRef(false); + const isAgentModeMountedRef = useRef(true); const projectOrderRequestIdRef = useRef(0); const applyAuthoritativeMode = useCallback((value: AgentPermissionMode) => { @@ -323,10 +334,19 @@ export function AgentMode({ userId }: { userId: string }) { }, []); useEffect(() => { - if (isCompactLayout) { + isAgentModeMountedRef.current = true; + return () => { + isAgentModeMountedRef.current = false; + }; + }, []); + + useEffect(() => { + const wasCompactLayout = previousIsCompactLayoutRef.current; + previousIsCompactLayoutRef.current = isCompactLayout; + if (isCompactLayout && !wasCompactLayout) { setIsSidebarOpen(false); } - }, [isCompactLayout]); + }, [isCompactLayout, setIsSidebarOpen]); useEffect(() => { activeSessionIdRef.current = activeSessionId; @@ -451,7 +471,7 @@ export function AgentMode({ userId }: { userId: string }) { return ids; }, [activeRunsBySession, pendingSendSessionIds, pendingSessionSelectionId]); - const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), []); + const toggleSidebar = useCallback(() => setIsSidebarOpen((prev) => !prev), [setIsSidebarOpen]); const beginSessionSelection = useCallback((sessionId: string): number => { interactionGenerationRef.current += 1; @@ -625,18 +645,24 @@ export function AgentMode({ userId }: { userId: string }) { const enqueueProjectRootMutation = useCallback( async (mutation: () => Promise): Promise => { - const previousOperation = projectRootPersistenceRef.current; + const previousOperation = projectRootPersistenceQueues.get(userId) ?? Promise.resolve(); const operation = (async () => { await previousOperation; return await trackAgentWorkflow(mutation); })(); - projectRootPersistenceRef.current = operation.then( + const queueTail = operation.then( () => undefined, () => undefined ); + projectRootPersistenceQueues.set(userId, queueTail); + void queueTail.then(() => { + if (projectRootPersistenceQueues.get(userId) === queueTail) { + projectRootPersistenceQueues.delete(userId); + } + }); return await operation; }, - [trackAgentWorkflow] + [trackAgentWorkflow, userId] ); const persistSelectedProjectRoot = useCallback( @@ -720,6 +746,7 @@ export function AgentMode({ userId }: { userId: string }) { if (!isTauriDesktop()) return; const nextSessions = await agentRuntimeService.listSessions(userId, null); setSessions(nextSessions.filter((session) => !deletedSessionIdsRef.current.has(session.id))); + setIsSessionHistoryReady(true); }); }, [trackAgentWorkflow, userId]); @@ -851,6 +878,13 @@ export function AgentMode({ userId }: { userId: string }) { async function loadInitialState() { if (!isTauriDesktop()) return; try { + // A mode switch can remount AgentMode while the previous mount is still saving a selected + // root or manual order. Read only after that user-scoped queue reaches its latest tail. + await (projectRootPersistenceQueues.get(userId) ?? Promise.resolve()); + if (cancelled || interactionGenerationRef.current !== initializationGeneration) { + return; + } + const runStateGeneration = runStateGenerationRef.current; const [status, config, roots, savedMcpServers] = await Promise.all([ agentRuntimeService.getRuntimeStatus(userId), @@ -958,6 +992,7 @@ export function AgentMode({ userId }: { userId: string }) { }); if (typeof selected === "string") { invalidateSessionSelection(); + agentSessionSelection.forget(userId); shouldAutoScrollRef.current = true; setProjectRoot(selected); activeSessionIdRef.current = null; @@ -975,11 +1010,18 @@ export function AgentMode({ userId }: { userId: string }) { } catch (chooseError) { setError(errorMessage(chooseError)); } - }, [invalidateSessionSelection, registerProjectRoot, trackAgentWorkflow]); + }, [ + agentSessionSelection, + invalidateSessionSelection, + registerProjectRoot, + trackAgentWorkflow, + userId + ]); const selectProjectRoot = useCallback( (value: string) => { invalidateSessionSelection(); + agentSessionSelection.forget(userId); const interactionGeneration = interactionGenerationRef.current; setProjectRoot(value); setActiveSessionId(null); @@ -999,7 +1041,13 @@ export function AgentMode({ userId }: { userId: string }) { } })(); }, - [invalidateSessionSelection, persistSelectedProjectRoot, refreshSessions] + [ + agentSessionSelection, + invalidateSessionSelection, + persistSelectedProjectRoot, + refreshSessions, + userId + ] ); const selectModel = useCallback((value: string) => { @@ -1204,6 +1252,7 @@ export function AgentMode({ userId }: { userId: string }) { // A send that creates a session may finish after the user selects a // different chat. Keep the new chat/run, but never steal focus back. if ( + isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === expectedSelectionGeneration && interactionGenerationRef.current === expectedInteractionGeneration && activeSessionIdRef.current === null @@ -1211,6 +1260,7 @@ export function AgentMode({ userId }: { userId: string }) { shouldAutoScrollRef.current = true; activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); + agentSessionSelection.remember(userId, sessionId); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); replaceSessionTimeline(sessionId, detail.timeline); const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); @@ -1222,6 +1272,7 @@ export function AgentMode({ userId }: { userId: string }) { }, [ applyAuthoritativeMode, + agentSessionSelection, model, projectRoot, replaceSessionTimeline, @@ -1257,12 +1308,14 @@ export function AgentMode({ userId }: { userId: string }) { replaceSessionTimeline(detail.session.id, detail.timeline); if ( + isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === selectionGeneration && interactionGenerationRef.current === interactionGeneration ) { shouldAutoScrollRef.current = true; activeSessionIdRef.current = detail.session.id; setActiveSessionId(detail.session.id); + agentSessionSelection.remember(userId, detail.session.id); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); replaceSessionTimeline(detail.session.id, detail.timeline); const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); @@ -1270,16 +1323,20 @@ export function AgentMode({ userId }: { userId: string }) { } } catch (createError) { if ( + isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === selectionGeneration && interactionGenerationRef.current === interactionGeneration ) { setError(errorMessage(createError)); } } finally { - finishSessionSelection(selectionGeneration); + if (isAgentModeMountedRef.current) { + finishSessionSelection(selectionGeneration); + } } }, [ applyAuthoritativeMode, + agentSessionSelection, beginSessionSelection, finishSessionSelection, model, @@ -1311,6 +1368,7 @@ export function AgentMode({ userId }: { userId: string }) { }); const { detail, timelineRevision } = loaded; if ( + !isAgentModeMountedRef.current || sessionSelectionGenerationRef.current !== selectionGeneration || interactionGenerationRef.current !== interactionGeneration || deletedSessionIdsRef.current.has(sessionId) @@ -1331,6 +1389,7 @@ export function AgentMode({ userId }: { userId: string }) { shouldAutoScrollRef.current = true; activeSessionIdRef.current = detail.session.id; setActiveSessionId(detail.session.id); + agentSessionSelection.remember(userId, detail.session.id); setProjectRoot(detail.session.projectRoot); if (detail.session.model) { setModel(detail.session.model); @@ -1345,6 +1404,7 @@ export function AgentMode({ userId }: { userId: string }) { await persistSelectedProjectRoot(detail.session.projectRoot); } catch (persistError) { if ( + isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === selectionGeneration && interactionGenerationRef.current === interactionGeneration && activeSessionIdRef.current === detail.session.id @@ -1354,17 +1414,21 @@ export function AgentMode({ userId }: { userId: string }) { } } catch (loadError) { if ( + isAgentModeMountedRef.current && sessionSelectionGenerationRef.current === selectionGeneration && interactionGenerationRef.current === interactionGeneration ) { setError(errorMessage(loadError)); } } finally { - finishSessionSelection(selectionGeneration); + if (isAgentModeMountedRef.current) { + finishSessionSelection(selectionGeneration); + } } }, [ applyAuthoritativeMode, + agentSessionSelection, beginSessionSelection, clearCompletedUnreadSession, finishSessionSelection, @@ -1375,6 +1439,31 @@ export function AgentMode({ userId }: { userId: string }) { ] ); + useEffect(() => { + if ( + hasAttemptedSessionRestoreRef.current || + !isAuthTransitionReady || + isInitializing || + !isSessionHistoryReady + ) { + return; + } + + hasAttemptedSessionRestoreRef.current = true; + const rememberedSessionId = agentSessionSelection.resolve(userId, sessions); + if (rememberedSessionId) { + void loadSession(rememberedSessionId); + } + }, [ + agentSessionSelection, + isAuthTransitionReady, + isInitializing, + isSessionHistoryReady, + loadSession, + sessions, + userId + ]); + const sendMessage = useCallback(async () => { const text = input.trim(); const requestedSessionId = activeSessionIdRef.current; @@ -1548,6 +1637,7 @@ export function AgentMode({ userId }: { userId: string }) { const removeSessionFromState = useCallback( (sessionId: string) => { deletedSessionIdsRef.current.add(sessionId); + agentSessionSelection.forget(userId, sessionId); timelineRevisionBySessionRef.current.delete(sessionId); setSessions((current) => current.filter((session) => session.id !== sessionId)); setCompletedUnreadSessionIds((current) => { @@ -1567,7 +1657,7 @@ export function AgentMode({ userId }: { userId: string }) { setInput(""); } }, - [clearActiveRun] + [agentSessionSelection, clearActiveRun, userId] ); const deleteSession = useCallback( @@ -1576,6 +1666,11 @@ export function AgentMode({ userId }: { userId: string }) { try { await agentRuntimeService.deleteSession(userId, sessionId); removeSessionFromState(sessionId); + // A mode switch can remount AgentMode while native deletion is pending. + // Notify the current mount so it cannot keep or restore the deleted session. + window.dispatchEvent( + new CustomEvent(AGENT_SESSION_DELETED_EVENT, { detail: { userId, sessionId } }) + ); } catch (deleteError) { setError(errorMessage(deleteError)); } @@ -1583,6 +1678,21 @@ export function AgentMode({ userId }: { userId: string }) { [removeSessionFromState, userId] ); + useEffect(() => { + const handleSessionDeleted = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail as { userId?: unknown; sessionId?: unknown } | null; + if (detail?.userId === userId && typeof detail.sessionId === "string" && detail.sessionId) { + removeSessionFromState(detail.sessionId); + } + }; + + window.addEventListener(AGENT_SESSION_DELETED_EVENT, handleSessionDeleted); + return () => { + window.removeEventListener(AGENT_SESSION_DELETED_EVENT, handleSessionDeleted); + }; + }, [removeSessionFromState, userId]); + const upsertSessionSummary = useCallback((summary: AgentSessionSummary) => { if (deletedSessionIdsRef.current.has(summary.id)) return; setSessions((current) => { diff --git a/frontend/src/components/AuthenticatedHomeContent.tsx b/frontend/src/components/AuthenticatedHomeContent.tsx index 35ef5d77..2be97347 100644 --- a/frontend/src/components/AuthenticatedHomeContent.tsx +++ b/frontend/src/components/AuthenticatedHomeContent.tsx @@ -8,9 +8,11 @@ import { type ReactNode } from "react"; import { useLocation, useRouter } from "@tanstack/react-router"; +import { useOpenSecret } from "@opensecret/react"; import { ProjectDetailView } from "@/components/ProjectDetailView"; import { UnifiedChat } from "@/components/UnifiedChat"; import { PersistentHomeNavigationContext } from "@/contexts/PersistentHomeNavigationContext"; +import { AgentSessionSelectionMemory } from "@/services/agentSessionSelection"; const TRANSIENT_HOME_SEARCH_PARAMS = ["team_setup", "credits_success", "api_settings"]; @@ -48,21 +50,38 @@ function readSafeHomeHref(): string { export function PersistentHomeNavigationProvider({ children }: { children: ReactNode }) { const location = useLocation(); const router = useRouter(); + const os = useOpenSecret(); + const userId = os.auth.user?.user.id ?? null; const initialHomeHref = readSafeHomeHref(); const homeHrefRef = useRef(initialHomeHref); - const [homeHref, setHomeHref] = useState(initialHomeHref); + const homeHrefUserIdRef = useRef(userId); + const skipNextHomeCaptureRef = useRef(false); + const [sidebarOpen, setSidebarOpen] = useState(null); + const [agentSessionSelection] = useState(() => new AgentSessionSelectionMemory()); const captureHomeHref = useCallback(() => { - if (window.location.pathname !== "/") return; + if (!userId || window.location.pathname !== "/") return; const nextHomeHref = readSafeHomeHref(); homeHrefRef.current = nextHomeHref; - setHomeHref((current) => (current === nextHomeHref ? current : nextHomeHref)); - }, []); + }, [userId]); + + useLayoutEffect(() => { + if (homeHrefUserIdRef.current === userId) return; + + // Conversation and project IDs are account-scoped; never carry a home snapshot across users. + homeHrefUserIdRef.current = userId; + skipNextHomeCaptureRef.current = true; + homeHrefRef.current = "/"; + }, [userId]); // TanStack navigations update location.href. Maple also updates the home URL with the native // History API, so the app events below keep the snapshot exact for those transitions too. useLayoutEffect(() => { + if (skipNextHomeCaptureRef.current) { + skipNextHomeCaptureRef.current = false; + return; + } captureHomeHref(); }, [captureHomeHref, location.href]); @@ -100,10 +119,12 @@ export function PersistentHomeNavigationProvider({ children }: { children: React const value = useMemo( () => ({ - homeHref, - returnToHome + returnToHome, + sidebarOpen, + setSidebarOpen, + agentSessionSelection }), - [homeHref, returnToHome] + [agentSessionSelection, returnToHome, sidebarOpen] ); return ( diff --git a/frontend/src/components/ProjectDetailView.tsx b/frontend/src/components/ProjectDetailView.tsx index c5e4ca7b..e6197a4f 100644 --- a/frontend/src/components/ProjectDetailView.tsx +++ b/frontend/src/components/ProjectDetailView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CheckSquare, Folder, @@ -47,6 +47,7 @@ import { BulkDeleteDialog } from "@/components/BulkDeleteDialog"; import { MoveChatsDialog } from "@/components/MoveChatsDialog"; import { listAllConversationProjects } from "@/utils/paginatedLists"; import { SIDEBAR_GRID_COLUMNS_CLASS, SIDEBAR_LAYOUT_STYLE } from "@/constants/layout"; +import { usePersistentSidebarState } from "@/contexts/PersistentHomeNavigationContext"; const PROJECT_PAGE_SIZE = 20; const MAX_SELECTION = 20; @@ -151,13 +152,16 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { const { setSelectedProjectId } = useLocalState(); const hasAuthUser = !!os.auth.user; - const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); + const [isSidebarOpen, setIsSidebarOpen] = usePersistentSidebarState(isCompactLayout); + const wasLandscapeMobileRef = useRef(isLandscapeMobile); useEffect(() => { - if (isLandscapeMobile && isSidebarOpen) { + const enteredLandscapeMobile = isLandscapeMobile && !wasLandscapeMobileRef.current; + wasLandscapeMobileRef.current = isLandscapeMobile; + if (enteredLandscapeMobile && isSidebarOpen) { setIsSidebarOpen(false); } - }, [isLandscapeMobile]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isLandscapeMobile, isSidebarOpen, setIsSidebarOpen]); const [conversations, setConversations] = useState([]); const [hasMoreConversations, setHasMoreConversations] = useState(false); @@ -179,7 +183,7 @@ export function ProjectDetailView({ projectId }: ProjectDetailViewProps) { const toggleSidebar = useCallback(() => { setIsSidebarOpen((prev) => !prev); - }, []); + }, [setIsSidebarOpen]); useEffect(() => { setSelectedProjectId(projectId); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index b132b186..722032c3 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,8 +6,7 @@ import { XCircle, Trash2, X, - FolderInput, - Bot + FolderInput } from "lucide-react"; import { Button } from "./ui/button"; import { useLocation, useRouter } from "@tanstack/react-router"; @@ -37,6 +36,8 @@ import { useOpenSecret } from "@opensecret/react"; import { FEATURE_FLAGS, flagsClient } from "@/services/flags"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; import { hasApiAccess } from "@/billing/billingAccess"; +import { WorkspaceModeSwitch, type WorkspaceMode } from "@/components/WorkspaceModeSwitch"; +import { usePersistentHomeNavigation } from "@/contexts/PersistentHomeNavigationContext"; export function Sidebar({ chatId, @@ -47,12 +48,13 @@ export function Sidebar({ }: { chatId?: string; isOpen: boolean; - mode?: "chat" | "agent"; + mode?: WorkspaceMode; navigationContent?: ReactNode; onToggle: () => void; }) { const router = useRouter(); const location = useLocation(); + const { returnToHome } = usePersistentHomeNavigation(); const os = useOpenSecret(); const userId = os.auth.user?.user.id; const { @@ -70,6 +72,8 @@ export function Sidebar({ const [isSelectionMode, setIsSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [agentModeUpgradeOpen, setAgentModeUpgradeOpen] = useState(false); + const [pendingWorkspaceMode, setPendingWorkspaceMode] = useState(null); + const workspaceModeNavigationStartedRef = useRef(false); // Enter selection mode when items are selected (e.g., via long press) useEffect(() => { @@ -133,10 +137,33 @@ export function Sidebar({ } } - async function toggleAgentMode() { - const isLeavingAgentMode = location.pathname === "/agent"; + async function completeWorkspaceModeChange(nextMode: WorkspaceMode) { + if (workspaceModeNavigationStartedRef.current) return; + workspaceModeNavigationStartedRef.current = true; - if (!isLeavingAgentMode) { + if (nextMode === "chat") { + returnToHome({ replace: false }); + return; + } + + try { + await router.navigate({ to: "/agent" }); + } catch (error) { + workspaceModeNavigationStartedRef.current = false; + setPendingWorkspaceMode(null); + console.error("Navigation failed:", error); + } + } + + function switchWorkspaceMode(nextMode: WorkspaceMode) { + if (pendingWorkspaceMode !== null) { + if (nextMode === mode) setPendingWorkspaceMode(null); + return; + } + + if (nextMode === mode) return; + + if (nextMode === "agent") { if (billingStatus === null) return; if (!hasApiAccess(billingStatus)) { @@ -145,15 +172,12 @@ export function Sidebar({ } } - if (isOpen) { - onToggle(); + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + void completeWorkspaceModeChange(nextMode); + return; } - try { - await router.navigate({ to: isLeavingAgentMode ? "/" : "/agent" }); - } catch (error) { - console.error("Navigation failed:", error); - } + setPendingWorkspaceMode(nextMode); } const toggleSearch = () => { @@ -190,11 +214,16 @@ export function Sidebar({ userId: string; enabled: boolean; } | null>(null); - const showAgentMode = - agentModeAvailable && - billingStatus !== null && - agentModeFlag?.userId === userId && - agentModeFlag?.enabled === true; + // Chat and Agent mount separate Sidebar instances. Seed a remount from the existing, + // user-scoped flag cache while this instance revalidates in the background. + const cachedAgentModeEnabled = userId + ? flagsClient.peekIsEnabled(userId, FEATURE_FLAGS.AGENT_MODE) + : undefined; + const agentModeEnabled = + agentModeFlag !== null && agentModeFlag.userId === userId + ? agentModeFlag.enabled + : cachedAgentModeEnabled; + const showAgentMode = agentModeAvailable && billingStatus !== null && agentModeEnabled === true; const isAgentMode = mode === "agent"; useEffect(() => { @@ -256,6 +285,11 @@ export function Sidebar({ // Prevent updates if component unmounted if (!isMountedRef.current) return; + const resolvedPath = window.location.pathname; + const isWorkspaceModeTransition = + (isAgentMode && resolvedPath === "/") || (!isAgentMode && resolvedPath === "/agent"); + if (isWorkspaceModeTransition) return; + // Double-check conditions after async boundary if (isOpen && isCompactLayout) { onToggle(); @@ -266,7 +300,7 @@ export function Sidebar({ return () => { unsubscribe(); }; - }, [router, isOpen, onToggle, isCompactLayout]); + }, [router, isOpen, onToggle, isCompactLayout, isAgentMode]); return (
+ {(showAgentMode || isAgentMode) && ( +
+ { + if (nextMode === pendingWorkspaceMode) { + void completeWorkspaceModeChange(nextMode); + } + }} + /> +
+ )}
- {showAgentMode && ( - - )} {!isAgentMode && (