phase5: workspace frontend — API hooks, useWorkspace hook, WorkspaceSwitcher, Sidebar workspace filter

This commit is contained in:
Agent Zero
2026-07-29 22:12:23 +02:00
parent bd50a85483
commit fca7191269
5 changed files with 276 additions and 2 deletions
+11 -2
View File
@@ -8,6 +8,7 @@ import { usePluginStore } from '@/store/pluginStore';
import * as LucideIcons from 'lucide-react';
import { useMenuOrder } from '@/api/users';
import { usePermission } from '@/hooks/usePermission';
import { useWorkspace } from '@/hooks/useWorkspace';
import { useAuthStore } from '@/store/authStore';
interface NavSingleItem {
@@ -44,6 +45,7 @@ export function Sidebar() {
const { data: menuOrderData } = useMenuOrder();
const { hasPermission } = usePermission();
const user = useAuthStore((state) => state.user);
const { isModuleVisible } = useWorkspace();
// Use hasPermission directly — permissions are loaded via useUserPermissions hook
const canAccess = (perm?: string): boolean => {
@@ -78,12 +80,19 @@ export function Sidebar() {
}));
const allItems = [...staticItems, ...pluginItems];
// Filter by workspace visibility (in addition to permissions)
// If no workspace is active, isModuleVisible returns true (backward compatible)
const workspaceFiltered = allItems.filter(item => {
const moduleKey = item.path.replace(/^\//, '').split('/')[0];
return isModuleVisible(moduleKey);
});
// Apply saved menu order if available
const savedOrder = menuOrderData?.menu_order;
if (savedOrder && savedOrder.length > 0) {
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
return allItems.sort((a, b) => {
return workspaceFiltered.sort((a, b) => {
// Map path to menu ID: static items use their path without leading /
const aId = a.path.startsWith('/') ? a.path.slice(1) : a.path;
const bId = b.path.startsWith('/') ? b.path.slice(1) : b.path;
@@ -98,7 +107,7 @@ export function Sidebar() {
});
}
return allItems.sort((a, b) => {
return workspaceFiltered.sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
return a.label.localeCompare(b.label);
});
@@ -10,6 +10,7 @@ import { SearchDropdown } from '@/components/shared/SearchDropdown';
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
import { NotificationBell } from '@/components/layout/NotificationBell';
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
import { useWindowStore } from '@/store/windowStore';
import { usePermission } from '@/hooks/usePermission';
@@ -87,6 +88,7 @@ export function TopBar() {
</div>
<div className="flex items-center gap-2">
<WorkspaceSwitcher />
<NotificationBell />
{/* Minimized windows */}
{minimizedWindows.length > 0 && (
@@ -0,0 +1,65 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useWorkspace } from '@/hooks/useWorkspace';
import { LayoutGrid, ChevronDown, Check } from 'lucide-react';
export function WorkspaceSwitcher() {
const { t } = useTranslation();
const { myWorkspaces, activeWorkspaceId, switchWorkspace, hasWorkspaces } = useWorkspace();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (!hasWorkspaces) return null;
const activeWs = myWorkspaces.find(w => w.id === activeWorkspaceId);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors"
title={activeWs?.description || activeWs?.name || t('workspaces.select')}
>
<LayoutGrid className="w-4 h-4" />
<span className="max-w-[120px] truncate">{activeWs?.name || t('workspaces.select')}</span>
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-64 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-50 py-1">
<div className="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('workspaces.myWorkspaces')}
</div>
{myWorkspaces.map(ws => (
<button
key={ws.id}
onClick={() => {
switchWorkspace(ws.id);
setOpen(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 transition-colors"
>
<span className="flex-1 text-left">
<span className="block font-medium">{ws.name}</span>
{ws.description && (
<span className="block text-xs text-gray-500 dark:text-gray-400 truncate">{ws.description}</span>
)}
</span>
{ws.id === activeWorkspaceId && <Check className="w-4 h-4 text-blue-500" />}
</button>
))}
</div>
)}
</div>
);
}