diff --git a/frontend/package.json b/frontend/package.json
index 00ba6c9ba8..06adbec282 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -118,6 +118,7 @@
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.8.0",
"typescript": "^5.5.4",
+ "typescript-plugin-css-modules": "^5.2.0",
"usehooks-ts": "^3.1.0",
"vite": "^5.2.0",
"vite-plugin-favicons-inject": "^2.2.0",
diff --git a/frontend/src/app/actor-builds-list.tsx b/frontend/src/app/actor-builds-list.tsx
index 190b79dfd8..9044c9b8d9 100644
--- a/frontend/src/app/actor-builds-list.tsx
+++ b/frontend/src/app/actor-builds-list.tsx
@@ -4,8 +4,13 @@ import {
Icon,
} from "@rivet-gg/icons";
import { useInfiniteQuery } from "@tanstack/react-query";
-import { Link, useNavigate } from "@tanstack/react-router";
+import {
+ Link,
+ type LinkComponentProps,
+ useNavigate,
+} from "@tanstack/react-router";
import { Fragment } from "react";
+import { match } from "ts-pattern";
import { Button, cn, Skeleton } from "@/components";
import { ACTORS_PER_PAGE, useManager } from "@/components/actors";
import { VisibilitySensor } from "@/components/visibility-sensor";
@@ -14,7 +19,7 @@ export function ActorBuildsList() {
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(useManager().buildsQueryOptions());
- const navigate = useNavigate({ from: "/" });
+ const navigate = useNavigate();
return (
@@ -41,10 +46,15 @@ export function ActorBuildsList() {
size="sm"
onClick={() => {
navigate({
- to:
- __APP_TYPE__ === "engine"
- ? "/ns/$namespace"
- : "/",
+ to: match(__APP_TYPE__)
+ .with("engine", () => "/ns/$namespace")
+ .with(
+ "cloud",
+ () =>
+ "/orgs/$organization/projects/$project/ns/$namespace",
+ )
+ .otherwise(() => "/"),
+
search: (old) => ({
...old,
n: [build.name],
diff --git a/frontend/src/app/context-switcher.tsx b/frontend/src/app/context-switcher.tsx
new file mode 100644
index 0000000000..b37cc0c12a
--- /dev/null
+++ b/frontend/src/app/context-switcher.tsx
@@ -0,0 +1,558 @@
+import { useClerk, useOrganizationList } from "@clerk/clerk-react";
+import { AvatarImage } from "@radix-ui/react-avatar";
+import { faPlusCircle, Icon } from "@rivet-gg/icons";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import { useMatchRoute, useNavigate, useParams } from "@tanstack/react-router";
+import { useState } from "react";
+import {
+ Avatar,
+ Button,
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ cn,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Skeleton,
+} from "@/components";
+import { useManager } from "@/components/actors";
+import { SafeHover } from "@/components/safe-hover";
+import { VisibilitySensor } from "@/components/visibility-sensor";
+import {
+ namespaceQueryOptions,
+ organizationQueryOptions,
+ projectQueryOptions,
+ projectsQueryOptions,
+} from "@/queries/manager-cloud";
+
+export function ContextSwitcher() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+
+
+ setIsOpen(false)} />
+
+
+ );
+}
+
+function Breadcrumbs() {
+ const match = useMatchRoute();
+
+ const matchNamespace = match({
+ to: "/orgs/$organization/projects/$project/ns/$namespace",
+ fuzzy: true,
+ });
+ if (matchNamespace) {
+ return (
+ <>
+
+
+
+ >
+ );
+ }
+
+ const matchProject = match({
+ to: "/orgs/$organization/projects/$project",
+ });
+
+ if (matchProject) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ const matchOrg = match({
+ to: "/orgs/$organization",
+ });
+
+ if (matchOrg) {
+ return
;
+ }
+}
+
+function OrganizationBreadcrumb({
+ org,
+ className,
+}: {
+ org: string;
+ className?: string;
+}) {
+ const { isLoading, data } = useQuery(organizationQueryOptions({ org }));
+ if (isLoading) {
+ return
;
+ }
+
+ return (
+
+ );
+}
+
+function ProjectBreadcrumb({
+ project,
+ className,
+}: {
+ project: string;
+ className?: string;
+}) {
+ const { isLoading, data } = useQuery(projectQueryOptions({ project }));
+ if (isLoading) {
+ return
;
+ }
+
+ return
{data?.name};
+}
+
+function NamespaceBreadcrumb({
+ namespace,
+ project,
+ className,
+}: {
+ namespace: string;
+ project: string;
+ className?: string;
+}) {
+ const { isLoading, data } = useQuery(
+ namespaceQueryOptions({ project, namespace }),
+ );
+ if (isLoading) {
+ return
;
+ }
+
+ return
{data?.name};
+}
+
+function Content({ onClose }: { onClose?: () => void }) {
+ const params = useParams({ strict: false });
+ const {
+ userMemberships: {
+ data: userMemberships = [],
+ isLoading,
+ hasNextPage,
+ fetchNext,
+ },
+ } = useOrganizationList({
+ userMemberships: {
+ infinite: true,
+ },
+ });
+ const clerk = useClerk();
+
+ const [currentOrgHover, setCurrentOrgHover] = useState
(
+ params.organization || null,
+ );
+
+ const [currentProjectHover, setCurrentProjectHover] = useState<
+ string | null
+ >(params.project || null);
+
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+ {!isLoading ? (
+
+ No organizations found.
+ }
+ onClick={() => {
+ onClose?.();
+ clerk.openCreateOrganization();
+ }}
+ >
+ Create Organization
+
+
+ ) : null}
+ {userMemberships.map((membership) => (
+
+ {
+ clerk.setActive({
+ organization:
+ membership.organization.id,
+ });
+ navigate({
+ to: "/orgs/$organization",
+ params: {
+ organization:
+ membership.organization
+ .id,
+ },
+ });
+ onClose?.();
+ }}
+ value={membership.organization.id}
+ onMouseEnter={() => {
+ setCurrentOrgHover(
+ membership.organization.id,
+ );
+ setCurrentProjectHover(null);
+ }}
+ keywords={[
+ membership.organization.name,
+ ]}
+ className="static cursor-pointer"
+ >
+ {membership.organization.name}
+
+
+ ))}
+ {isLoading ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+ {
+ setCurrentOrgHover(null);
+ setCurrentProjectHover(null);
+ }}
+ onFocus={() => {
+ setCurrentOrgHover(null);
+ setCurrentProjectHover(null);
+ }}
+ onSelect={() => {
+ clerk.openCreateOrganization();
+ }}
+ >
+
+ Create Organization
+
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+
+ {currentOrgHover ? (
+
+ ) : null}
+ {currentProjectHover && currentOrgHover ? (
+
+ ) : null}
+
+ );
+}
+
+function ProjectList({
+ organization,
+ onClose,
+ onHover,
+}: {
+ organization: string;
+ onClose?: () => void;
+ onHover?: (project: string | null) => void;
+}) {
+ const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+ useInfiniteQuery(projectsQueryOptions({ organization: organization }));
+ const navigate = useNavigate();
+ const clerk = useClerk();
+ const project = useParams({
+ strict: false,
+ select(params) {
+ return params.project;
+ },
+ });
+
+ return (
+
+
+
+
+
+ {!isLoading ? (
+
+ No projects found.
+ }
+ onClick={() => {
+ onHover?.(null);
+ navigate({
+ to: ".",
+ search: (old) => ({
+ ...old,
+ modal: "create-project",
+ }),
+ });
+ }}
+ >
+ Create Project
+
+
+ ) : null}
+
+ {data?.map((project) => (
+
+ {
+ clerk.setActive({
+ organization,
+ });
+ navigate({
+ to: "/orgs/$organization/projects/$project",
+ params: {
+ organization: organization,
+ project: project.name,
+ },
+ });
+ onClose?.();
+ }}
+ onMouseEnter={() => {
+ onHover?.(project.name);
+ }}
+ onFocus={() => {
+ onHover?.(project.name);
+ }}
+ >
+
+ {project.displayName}
+
+
+
+ ))}
+ {isLoading || isFetchingNextPage ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+ {
+ onHover?.(null);
+ navigate({
+ to: ".",
+ search: (old) => ({
+ ...old,
+ modal: "create-project",
+ }),
+ });
+ }}
+ >
+
+ Create Project
+
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function ListItemSkeleton() {
+ return (
+
+
+
+ );
+}
+
+function NamespaceList({
+ organization,
+ project,
+ onClose,
+}: {
+ organization: string;
+ project: string;
+ onClose?: () => void;
+}) {
+ const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
+ useInfiniteQuery(useManager().projectNamespacesQueryOptions(project));
+ const navigate = useNavigate();
+ const clerk = useClerk();
+ const namespace = useParams({
+ strict: false,
+ select(params) {
+ return params.namespace;
+ },
+ });
+
+ return (
+
+
+
+
+
+ {!isLoading ? (
+
+ No namespaces found.
+ }
+ onClick={() => {
+ navigate({
+ to: ".",
+ search: (old) => ({
+ ...old,
+ modal: "create-ns",
+ }),
+ });
+ }}
+ >
+ Create Namespace
+
+
+ ) : null}
+
+ {data?.map((namespace) => (
+
+ {
+ clerk.setActive({
+ organization,
+ });
+ navigate({
+ to: "/orgs/$organization/projects/$project/ns/$namespace",
+ params: {
+ organization: organization,
+ project: project,
+ namespace: namespace.name,
+ },
+ });
+ onClose?.();
+ }}
+ >
+
+ {namespace.displayName}
+
+
+
+ ))}
+ {isLoading || isFetchingNextPage ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+ {
+ navigate({
+ to: ".",
+ search: (old) => ({
+ ...old,
+ modal: "create-ns",
+ }),
+ });
+ }}
+ >
+
+ Create Namespace
+
+
+ {hasNextPage ? (
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/dialogs/create-namespace-dialog.tsx b/frontend/src/app/dialogs/create-namespace-dialog.tsx
index f0e890d2c9..d5b17f46f2 100644
--- a/frontend/src/app/dialogs/create-namespace-dialog.tsx
+++ b/frontend/src/app/dialogs/create-namespace-dialog.tsx
@@ -1,41 +1,60 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
+import { useNavigate, useParams } from "@tanstack/react-router";
import * as CreateNamespaceForm from "@/app/forms/create-namespace-form";
import { DialogFooter, DialogHeader, DialogTitle, Flex } from "@/components";
+import { useManager } from "@/components/actors";
import { convertStringToId } from "@/lib/utils";
-import {
- managerClient,
- namespacesQueryOptions,
-} from "@/queries/manager-engine";
-export default function CreateNamespacesDialogContent() {
+const useCreateNamespace = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
- const { mutateAsync } = useMutation({
- mutationFn: async (data: { displayName: string; nameId: string }) => {
- const response = await managerClient.namespaces.create({
- displayName: data.displayName,
- name: data.nameId,
- });
-
- return response;
- },
- onSuccess: async (data) => {
- await queryClient.invalidateQueries(namespacesQueryOptions());
- navigate({
- to: "/ns/$namespace",
- params: { namespace: data.namespace.name },
- });
- },
- });
+ const params = useParams({ strict: false });
+
+ const manager = useManager();
+
+ return useMutation(
+ manager.createNamespaceMutationOptions({
+ onSuccess: async (data) => {
+ // Invalidate all queries to ensure fresh data
+ await queryClient.invalidateQueries(
+ manager.namespacesQueryOptions(),
+ );
+
+ if (__APP_TYPE__ === "cloud") {
+ if (!params.project || !params.organization) {
+ throw new Error("Missing required parameters");
+ }
+ // Navigate to the newly created namespace
+ navigate({
+ to: "/orgs/$organization/projects/$project/ns/$namespace",
+ params: {
+ organization: params.organization,
+ project: params.project,
+ namespace: data.name,
+ },
+ });
+ return;
+ }
+
+ navigate({
+ to: "/ns/$namespace",
+ params: { namespace: data.name },
+ });
+ },
+ }),
+ );
+};
+
+export default function CreateNamespacesDialogContent() {
+ const { mutateAsync } = useCreateNamespace();
return (
{
await mutateAsync({
displayName: values.name,
- nameId: values.slug || convertStringToId(values.name),
+ name: values.slug || convertStringToId(values.name),
});
}}
defaultValues={{ name: "", slug: "" }}
diff --git a/frontend/src/app/dialogs/create-project-dialog.tsx b/frontend/src/app/dialogs/create-project-dialog.tsx
index d4cbb8a252..213a4901f5 100644
--- a/frontend/src/app/dialogs/create-project-dialog.tsx
+++ b/frontend/src/app/dialogs/create-project-dialog.tsx
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useNavigate } from "@tanstack/react-router";
+import { useNavigate, useParams } from "@tanstack/react-router";
import * as CreateProjectForm from "@/app/forms/create-project-form";
import { DialogFooter, DialogHeader, DialogTitle, Flex } from "@/components";
import { convertStringToId } from "@/lib/utils";
@@ -7,21 +7,22 @@ import {
createProjectMutationOptions,
projectsQueryOptions,
} from "@/queries/manager-cloud";
-import {
- managerClient,
- namespacesQueryOptions,
-} from "@/queries/manager-engine";
export default function CreateProjectDialogContent() {
const queryClient = useQueryClient();
const navigate = useNavigate();
+ const params = useParams({ strict: false });
const { mutateAsync } = useMutation(
createProjectMutationOptions({
onSuccess: async (values) => {
- await queryClient.invalidateQueries({
- ...projectsQueryOptions(),
- });
+ if (params.organization) {
+ await queryClient.invalidateQueries({
+ ...projectsQueryOptions({
+ organization: params.organization,
+ }),
+ });
+ }
navigate({
to: "/orgs/$organization/projects/$project",
params: {
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 37ffabec4d..61d8da5dbc 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,4 +1,4 @@
-import { OrganizationSwitcher, useClerk } from "@clerk/clerk-react";
+import { useClerk } from "@clerk/clerk-react";
import {
faArrowUpRight,
faLink,
@@ -7,12 +7,7 @@ import {
Icon,
} from "@rivet-gg/icons";
import { useQuery } from "@tanstack/react-query";
-import {
- Link,
- useMatch,
- useMatchRoute,
- useNavigate,
-} from "@tanstack/react-router";
+import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
import {
type ComponentProps,
createContext,
@@ -28,6 +23,8 @@ import {
import type { ImperativePanelGroupHandle } from "react-resizable-panels";
import { match } from "ts-pattern";
import {
+ Avatar,
+ AvatarImage,
Button,
type ButtonProps,
cn,
@@ -44,6 +41,7 @@ import type { HeaderLinkProps } from "@/components/header/header-link";
import { ensureTrailingSlash } from "@/lib/utils";
import type { NamespaceNameId } from "@/queries/manager-engine";
import { ActorBuildsList } from "./actor-builds-list";
+import { ContextSwitcher } from "./context-switcher";
import { useInspectorCredentials } from "./credentials-context";
import { NamespaceSelect } from "./namespace-select";
@@ -176,6 +174,9 @@ const Sidebar = ({