feat(workspaces): implement workspace limits feature with backend integration and UI updates

This commit is contained in:
Anish Sarkar 2026-07-16 15:44:49 +05:30
parent 1385ff4789
commit 38b784fbac
12 changed files with 176 additions and 18 deletions

View file

@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeWorkspaceIdAtom = atom<string | null>(null);
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
skip: 0,
limit: 10,
owned_only: false,
export const workspaceLimitsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.workspaces.limits,
staleTime: Infinity,
queryFn: async () => {
return workspacesApiService.getWorkspaceLimits();
},
};
});
export const workspacesAtom = atomWithQuery((get) => {
const queryParams = get(workspacesQueryParamsAtom);
const workspaceLimits = get(workspaceLimitsAtom).data;
const queryParams: GetWorkspacesRequest["queryParams"] = {
skip: 0,
...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}),
owned_only: false,
};
return {
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),

View file

@ -14,7 +14,7 @@ import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom";
import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
@ -84,6 +84,7 @@ export function LayoutDataProvider({
refetch: refetchWorkspaces,
isSuccess: workspacesLoaded,
} = useAtomValue(workspacesAtom);
const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom);
const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom);
const currentThreadState = useAtomValue(currentThreadAtom);
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
@ -225,6 +226,13 @@ export function LayoutDataProvider({
createdAt: space.created_at,
}));
}, [workspacesData]);
const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user;
const ownedWorkspaceCount = workspaces.reduce(
(count, space) => count + (space.isOwner ? 1 : 0),
0
);
const isAtWorkspaceLimit =
maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser;
// Find active workspace from list, falling back to the route-scoped detail query.
const activeWorkspace: Workspace | null = useMemo(() => {
@ -335,8 +343,14 @@ export function LayoutDataProvider({
);
const handleAddWorkspace = useCallback(() => {
if (isAtWorkspaceLimit) {
toast.error(
`Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.`
);
return;
}
setIsCreateWorkspaceDialogOpen(true);
}, []);
}, [isAtWorkspaceLimit, maxWorkspacesPerUser]);
const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom);
@ -663,6 +677,8 @@ export function LayoutDataProvider({
onWorkspaceDelete={handleWorkspaceDeleteClick}
onWorkspaceSettings={handleWorkspaceSettings}
onAddWorkspace={handleAddWorkspace}
isAtWorkspaceLimit={isAtWorkspaceLimit}
maxWorkspacesPerUser={maxWorkspacesPerUser}
workspace={activeWorkspace}
navItems={navItems}
onNavItemClick={handleNavItemClick}

View file

@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
import { Button } from "@/components/ui/button";
@ -87,6 +88,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
);
} catch (error) {
console.error("Failed to create workspace:", error);
toast.error(error instanceof Error ? error.message : "Failed to create workspace");
setIsSubmitting(false);
}
};

View file

@ -20,6 +20,8 @@ interface IconRailProps {
onWorkspaceDelete?: (workspace: Workspace) => void;
onWorkspaceSettings?: (workspace: Workspace) => void;
onAddWorkspace: () => void;
isAtWorkspaceLimit?: boolean;
maxWorkspacesPerUser?: number;
isSingleRailMode?: boolean;
onNewChat?: () => void;
navItems?: NavItem[];
@ -42,6 +44,8 @@ export function IconRail({
onWorkspaceDelete,
onWorkspaceSettings,
onAddWorkspace,
isAtWorkspaceLimit = false,
maxWorkspacesPerUser,
isSingleRailMode = false,
onNewChat,
navItems = [],
@ -78,6 +82,10 @@ export function IconRail({
})),
]
: [];
const addWorkspaceLabel =
isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined
? `Workspace limit reached: ${maxWorkspacesPerUser}`
: "Add workspace";
return (
<div className={cn("flex h-full w-14 min-h-0 flex-col items-center", className)}>
@ -99,18 +107,21 @@ export function IconRail({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
</Button>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
disabled={isAtWorkspaceLimit}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50 disabled:opacity-50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add workspace
{addWorkspaceLabel}
</TooltipContent>
</Tooltip>

View file

@ -95,6 +95,8 @@ interface LayoutShellProps {
onWorkspaceDelete?: (workspace: Workspace) => void;
onWorkspaceSettings?: (workspace: Workspace) => void;
onAddWorkspace: () => void;
isAtWorkspaceLimit?: boolean;
maxWorkspacesPerUser?: number;
workspace: Workspace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
@ -198,6 +200,8 @@ export function LayoutShell({
onWorkspaceDelete,
onWorkspaceSettings,
onAddWorkspace,
isAtWorkspaceLimit = false,
maxWorkspacesPerUser,
workspace,
navItems,
onNavItemClick,
@ -280,6 +284,8 @@ export function LayoutShell({
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={onWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
isAtWorkspaceLimit={isAtWorkspaceLimit}
maxWorkspacesPerUser={maxWorkspacesPerUser}
workspace={workspace}
navItems={navItems}
onNavItemClick={onNavItemClick}
@ -352,6 +358,8 @@ export function LayoutShell({
onWorkspaceDelete={onWorkspaceDelete}
onWorkspaceSettings={onWorkspaceSettings}
onAddWorkspace={onAddWorkspace}
isAtWorkspaceLimit={isAtWorkspaceLimit}
maxWorkspacesPerUser={maxWorkspacesPerUser}
isSingleRailMode={false}
user={user}
onUserSettings={onUserSettings}

View file

@ -17,6 +17,8 @@ interface MobileSidebarProps {
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
isAtWorkspaceLimit?: boolean;
maxWorkspacesPerUser?: number;
workspace: Workspace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
@ -67,6 +69,8 @@ export function MobileSidebar({
onWorkspaceSelect,
onAddWorkspace,
isAtWorkspaceLimit = false,
maxWorkspacesPerUser,
workspace,
navItems,
onNavItemClick,
@ -107,6 +111,10 @@ export function MobileSidebar({
onChatSelect(chat);
onOpenChange(false);
};
const addWorkspaceLabel =
isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined
? `Workspace limit reached: ${maxWorkspacesPerUser}`
: "Add workspace";
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@ -136,10 +144,12 @@ export function MobileSidebar({
variant="ghost"
size="icon"
onClick={onAddWorkspace}
disabled={isAtWorkspaceLimit}
title={addWorkspaceLabel}
className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</div>
</ScrollArea>

View file

@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({
export const getWorkspacesResponse = z.array(workspace);
/**
* Workspace limits
*/
export const workspaceLimits = z.object({
max_workspaces_per_user: z.number(),
});
/**
* Create workspace
*/
@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({
// Inferred types
export type Workspace = z.infer<typeof workspace>;
export type WorkspaceLimits = z.infer<typeof workspaceLimits>;
export type GetWorkspacesRequest = z.infer<typeof getWorkspacesRequest>;
export type GetWorkspacesResponse = z.infer<typeof getWorkspacesResponse>;
export type CreateWorkspaceRequest = z.infer<typeof createWorkspaceRequest>;

View file

@ -19,11 +19,16 @@ import {
updateWorkspaceApiAccessResponse,
updateWorkspaceRequest,
updateWorkspaceResponse,
workspaceLimits,
} from "@/contracts/types/workspace.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class WorkspacesApiService {
getWorkspaceLimits = async () => {
return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits);
};
/**
* Get a list of workspaces with optional filtering and pagination
*/

View file

@ -49,6 +49,7 @@ export const cacheKeys = {
},
workspaces: {
all: ["workspaces"] as const,
limits: ["workspaces", "limits"] as const,
withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) =>
["workspaces", ...stableEntries(queries)] as const,
detail: (workspaceId: string) => ["workspaces", workspaceId] as const,