Skip to content

Commit 6430b46

Browse files
committed
Add workspace awareness to UI project selector
- Add workspaceId to /api/projects response and new /api/workspaces endpoint - Group projects by workspace in sidebar selector with ListSubheader - Show workspace chip badge in AppBar next to page title
1 parent 13c62ac commit 6430b46

5 files changed

Lines changed: 87 additions & 15 deletions

File tree

src/api/rest/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function createRestApp(projectManager: ProjectManager): express.Express {
3838
return {
3939
id,
4040
projectDir: p.config.projectDir,
41+
workspaceId: p.workspaceId ?? null,
4142
stats: {
4243
docs: p.docGraph ? p.docGraph.order : 0,
4344
code: p.codeGraph ? p.codeGraph.order : 0,
@@ -51,6 +52,18 @@ export function createRestApp(projectManager: ProjectManager): express.Express {
5152
res.json({ results: projects });
5253
});
5354

55+
// List workspaces
56+
app.get('/api/workspaces', (_req, res) => {
57+
const workspaces = projectManager.listWorkspaces().map(id => {
58+
const ws = projectManager.getWorkspace(id)!;
59+
return {
60+
id,
61+
projects: ws.config.projects,
62+
};
63+
});
64+
res.json({ results: workspaces });
65+
});
66+
5467
// Project stats
5568
app.get('/api/projects/:projectId/stats', (req, res) => {
5669
const p = (req as any).project;

ui/src/entities/project/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import { request, unwrapList, type ListResponse } from '@/shared/api/client.ts';
33
export interface ProjectInfo {
44
id: string;
55
projectDir: string;
6+
workspaceId: string | null;
67
stats: { docs: number; code: number; knowledge: number; files: number; tasks: number };
78
}
89

10+
export interface WorkspaceInfo {
11+
id: string;
12+
projects: string[];
13+
}
14+
915
export interface ProjectDetailedStats {
1016
docs: { nodes: number; edges: number } | null;
1117
code: { nodes: number; edges: number } | null;
@@ -19,6 +25,10 @@ export function listProjects(): Promise<ProjectInfo[]> {
1925
return request<ListResponse<ProjectInfo>>('/projects').then(unwrapList);
2026
}
2127

28+
export function listWorkspaces(): Promise<WorkspaceInfo[]> {
29+
return request<ListResponse<WorkspaceInfo>>('/workspaces').then(unwrapList);
30+
}
31+
2232
export function getProjectStats(projectId: string) {
2333
return request<ProjectDetailedStats>(`/projects/${projectId}/stats`);
2434
}

ui/src/entities/project/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { listProjects, getProjectStats, type ProjectInfo, type ProjectDetailedStats } from './api.ts';
1+
export { listProjects, listWorkspaces, getProjectStats, type ProjectInfo, type WorkspaceInfo, type ProjectDetailedStats } from './api.ts';
22
export { useProjects } from './useProjects.ts';
Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { useState, useEffect } from 'react';
2-
import { listProjects, type ProjectInfo } from './api.ts';
2+
import { listProjects, listWorkspaces, type ProjectInfo, type WorkspaceInfo } from './api.ts';
33

44
export function useProjects() {
55
const [projects, setProjects] = useState<ProjectInfo[]>([]);
6+
const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);
67
const [loading, setLoading] = useState(true);
78
const [error, setError] = useState<string | null>(null);
89

910
useEffect(() => {
10-
listProjects()
11-
.then(setProjects)
11+
Promise.all([listProjects(), listWorkspaces()])
12+
.then(([p, w]) => { setProjects(p); setWorkspaces(w); })
1213
.catch((e) => setError(e.message))
1314
.finally(() => setLoading(false));
1415
}, []);
1516

16-
return { projects, loading, error };
17+
return { projects, workspaces, loading, error };
1718
}

ui/src/widgets/layout/Layout.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useEffect, useState } from 'react';
22
import { Outlet, useNavigate, useLocation, useParams } from 'react-router-dom';
33
import {
4-
AppBar, Box, Drawer, IconButton, List, ListItemButton, ListItemIcon,
5-
ListItemText, Toolbar, Typography, Select, MenuItem,
4+
AppBar, Box, Chip, Drawer, IconButton, List, ListItemButton, ListItemIcon,
5+
ListItemText, ListSubheader, Toolbar, Typography, Select, MenuItem,
66
Divider, useTheme,
77
} from '@mui/material';
88
import MenuIcon from '@mui/icons-material/Menu';
@@ -19,7 +19,7 @@ import PsychologyIcon from '@mui/icons-material/Psychology';
1919
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
2020
import BuildIcon from '@mui/icons-material/Build';
2121
import MenuBookIcon from '@mui/icons-material/MenuBook';
22-
import { useProjects } from '@/entities/project/index.ts';
22+
import { useProjects, type WorkspaceInfo } from '@/entities/project/index.ts';
2323
import { useThemeMode } from '@/shared/lib/ThemeModeContext.tsx';
2424
import { WsProvider } from '@/shared/lib/useWebSocket.ts';
2525

@@ -73,15 +73,54 @@ function buildDocumentTitle(pathname: string): string {
7373
return parts.join(' :: ');
7474
}
7575

76+
/** Build grouped menu items: workspace subheaders + project items, then standalone projects. */
77+
function buildGroupedItems(
78+
projects: { id: string; workspaceId: string | null }[],
79+
workspaces: WorkspaceInfo[],
80+
): React.ReactNode[] {
81+
const items: React.ReactNode[] = [];
82+
const placed = new Set<string>();
83+
84+
for (const ws of workspaces) {
85+
const wsProjects = projects.filter(p => p.workspaceId === ws.id);
86+
if (wsProjects.length === 0) continue;
87+
items.push(
88+
<ListSubheader key={`ws-${ws.id}`} sx={{ lineHeight: '32px', fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
89+
{ws.id}
90+
</ListSubheader>
91+
);
92+
for (const p of wsProjects) {
93+
items.push(<MenuItem key={p.id} value={p.id}>{p.id}</MenuItem>);
94+
placed.add(p.id);
95+
}
96+
}
97+
98+
const standalone = projects.filter(p => !placed.has(p.id));
99+
if (standalone.length > 0 && workspaces.length > 0) {
100+
items.push(
101+
<ListSubheader key="ws-standalone" sx={{ lineHeight: '32px', fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
102+
Standalone
103+
</ListSubheader>
104+
);
105+
}
106+
for (const p of standalone) {
107+
items.push(<MenuItem key={p.id} value={p.id}>{p.id}</MenuItem>);
108+
}
109+
110+
return items;
111+
}
112+
76113
export default function Layout() {
77114
const [mobileOpen, setMobileOpen] = useState(false);
78-
const { projects, loading } = useProjects();
115+
const { projects, workspaces, loading } = useProjects();
79116
const navigate = useNavigate();
80117
const location = useLocation();
81118
const { projectId } = useParams();
82119
const { mode, toggle } = useThemeMode();
83120
const { palette } = useTheme();
84121

122+
const currentProject = projects.find(p => p.id === projectId);
123+
85124
const pageTitle = getPageTitle(location.pathname);
86125
const documentTitle = buildDocumentTitle(location.pathname);
87126

@@ -117,9 +156,7 @@ export default function Layout() {
117156
</Typography>
118157
)}
119158
>
120-
{projects.map((p) => (
121-
<MenuItem key={p.id} value={p.id}>{p.id}</MenuItem>
122-
))}
159+
{buildGroupedItems(projects, workspaces)}
123160
</Select>
124161
</Box>
125162
<Divider />
@@ -187,9 +224,20 @@ export default function Layout() {
187224
>
188225
<MenuIcon />
189226
</IconButton>
190-
<Typography variant="h6" noWrap sx={{ flexGrow: 1 }}>
191-
{pageTitle}
192-
</Typography>
227+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexGrow: 1 }}>
228+
<Typography variant="h6" noWrap>
229+
{pageTitle}
230+
</Typography>
231+
{currentProject?.workspaceId && (
232+
<Chip
233+
label={currentProject.workspaceId}
234+
size="small"
235+
variant="outlined"
236+
color="primary"
237+
sx={{ fontWeight: 600 }}
238+
/>
239+
)}
240+
</Box>
193241
<IconButton color="inherit" onClick={toggle} title={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}>
194242
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
195243
</IconButton>

0 commit comments

Comments
 (0)