diff --git a/src/browser/App.tsx b/src/browser/App.tsx index b01526a868..f2884b370c 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, useState } from "react"; import { useRouter } from "./contexts/RouterContext"; import { useLocation, useNavigate } from "react-router-dom"; import "./styles/globals.css"; @@ -59,7 +59,7 @@ import { markPendingWorkspaceAiSettings, } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; - +import { CreationCenterContent } from "@/browser/features/ChatInput/CreationCenterContent"; import { ProjectPage } from "@/browser/components/ProjectPage/ProjectPage"; import { SettingsProvider, useSettings } from "./contexts/SettingsContext"; @@ -86,6 +86,13 @@ import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { LandingPage } from "@/browser/features/LandingPage/LandingPage"; import { LoadingScreen } from "@/browser/components/LoadingScreen/LoadingScreen"; +interface WorkspaceCreationTransition { + workspaceId: string; + projectName: string; + workspaceName: string; + workspaceTitle?: string; +} + function AppInner() { // Get workspace state from context const { @@ -175,6 +182,41 @@ function AppInner() { const creationProjectPath = !selectedWorkspace && !currentWorkspaceId ? pendingNewWorkspaceProject : null; + const [workspaceCreationTransition, setWorkspaceCreationTransition] = + useState(null); + + const activeWorkspaceIdForTransition = + currentWorkspaceId ?? selectedWorkspace?.workspaceId ?? null; + + // Keep a single creation overlay visible while routing from ProjectPage → WorkspaceShell. + // Without this bridge the mobile UI shows two back-to-back loading screens with a + // restarted animation, which feels like a redundant second loading step. + useEffect(() => { + if (!workspaceCreationTransition) { + return; + } + + if (isAnalyticsOpen || currentSettingsSection) { + setWorkspaceCreationTransition(null); + return; + } + + if (activeWorkspaceIdForTransition !== workspaceCreationTransition.workspaceId) { + setWorkspaceCreationTransition(null); + } + }, [ + activeWorkspaceIdForTransition, + currentSettingsSection, + isAnalyticsOpen, + workspaceCreationTransition, + ]); + + const showWorkspaceCreationTransition = + workspaceCreationTransition !== null && + !isAnalyticsOpen && + !currentSettingsSection && + activeWorkspaceIdForTransition === workspaceCreationTransition.workspaceId; + // History navigation (back/forward) const navigate = useNavigate(); const location = useLocation(); @@ -192,6 +234,15 @@ function AppInner() { setSidebarCollapsed((prev) => !prev); }, [setSidebarCollapsed]); + const handleWorkspaceHydrated = useCallback((workspaceId: string) => { + setWorkspaceCreationTransition((prev) => { + if (prev?.workspaceId !== workspaceId) { + return prev; + } + return null; + }); + }, []); + // Telemetry tracking const telemetry = useTelemetry(); @@ -984,7 +1035,7 @@ function AppInner() {
-
+
{/* Route-driven settings and analytics render in the main pane so project/workspace navigation stays visible. */} {isAnalyticsOpen ? ( @@ -1067,6 +1119,13 @@ function AppInner() { setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata)); if (options?.autoNavigate !== false) { + setWorkspaceCreationTransition({ + workspaceId: metadata.id, + projectName, + workspaceName: metadata.name, + workspaceTitle: metadata.title, + }); + // Only switch to new workspace if user hasn't selected another one // during the creation process (selectedWorkspace was null when creation started) setSelectedWorkspace((current) => { @@ -1097,6 +1156,16 @@ function AppInner() { onToggleLeftSidebarCollapsed={handleToggleSidebar} /> )} + {showWorkspaceCreationTransition && workspaceCreationTransition && ( +
+ +
+ )}
({ workspaceId: selectedWorkspace?.workspaceId })} /> diff --git a/src/browser/components/AIView/AIView.tsx b/src/browser/components/AIView/AIView.tsx index bbd2644d78..bc9e87fef8 100644 --- a/src/browser/components/AIView/AIView.tsx +++ b/src/browser/components/AIView/AIView.tsx @@ -20,6 +20,8 @@ interface AIViewProps { className?: string; /** If set, workspace is incompatible (from newer mux version) and this error should be displayed */ incompatibleRuntime?: string; + /** Called once the workspace shell has hydrated initial state after navigation. */ + onWorkspaceHydrated?: (workspaceId: string) => void; /** True if workspace is still being initialized (postCreateSetup or initWorkspace running) */ isInitializing?: boolean; } diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index e6793c1aac..763e2f4775 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -1,5 +1,5 @@ import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; -import React, { useCallback, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { cn } from "@/common/lib/utils"; import { LoadingAnimation } from "../LoadingAnimation/LoadingAnimation"; import { RIGHT_SIDEBAR_WIDTH_KEY, getReviewImmersiveKey } from "@/common/constants/storage"; @@ -38,6 +38,8 @@ interface WorkspaceShellProps { onToggleLeftSidebarCollapsed: () => void; runtimeConfig?: RuntimeConfig; className?: string; + /** Called when initial workspace hydration finishes after navigation. */ + onWorkspaceHydrated?: (workspaceId: string) => void; /** True if workspace is still being initialized (postCreateSetup or initWorkspace running) */ isInitializing?: boolean; } @@ -68,6 +70,9 @@ const WorkspacePlaceholder: React.FC<{ ); export const WorkspaceShell: React.FC = (props) => { + const workspaceId = props.workspaceId; + const onWorkspaceHydrated = props.onWorkspaceHydrated; + const shellRef = useRef(null); const shellSize = useResizeObserver(shellRef); @@ -113,15 +118,15 @@ export const WorkspaceShell: React.FC = (props) => { // On mobile touch devices, always use popout since the right sidebar is hidden const isMobileTouch = window.matchMedia("(max-width: 768px) and (pointer: coarse)").matches; if (isMobileTouch) { - void openTerminalPopout(props.workspaceId, props.runtimeConfig, options); + void openTerminalPopout(workspaceId, props.runtimeConfig, options); } else { addTerminalRef.current?.(options); } }, - [openTerminalPopout, props.workspaceId, props.runtimeConfig] + [openTerminalPopout, props.runtimeConfig, workspaceId] ); - const reviews = useReviews(props.workspaceId); + const reviews = useReviews(workspaceId); const { addReview } = reviews; const handleReviewNote = useCallback( (data: ReviewNoteData) => { @@ -130,12 +135,18 @@ export const WorkspaceShell: React.FC = (props) => { [addReview] ); - const workspaceState = useWorkspaceState(props.workspaceId); - const [isReviewImmersive] = usePersistedState(getReviewImmersiveKey(props.workspaceId), false, { + const workspaceState = useWorkspaceState(workspaceId); + const [isReviewImmersive] = usePersistedState(getReviewImmersiveKey(workspaceId), false, { listener: true, }); const backgroundBashError = useBackgroundBashError(); + useEffect(() => { + if (!workspaceState.loading) { + onWorkspaceHydrated?.(workspaceId); + } + }, [onWorkspaceHydrated, workspaceId, workspaceState.loading]); + if (!workspaceState || workspaceState.loading) { return (