From 7c64cd9d512e50987e43468200488987d857ab3d Mon Sep 17 00:00:00 2001
From: dadachann <185672915+dadachann@users.noreply.github.com>
Date: Sat, 25 Jul 2026 16:26:13 +0800
Subject: [PATCH] feat(web): place workspace controls in sidebar
---
.../components/home-sidebar/HomeSidebar.tsx | 9 ++-
.../WorkspaceSettingsPanel.tsx | 29 ++++++--
.../workspace-settings/WorkspaceSwitcher.tsx | 2 +-
web/src/app/home/layout.tsx | 2 -
.../unit/workspace-switcher-layout.test.mjs | 69 +++++++++++++++++++
5 files changed, 99 insertions(+), 12 deletions(-)
create mode 100644 web/tests/unit/workspace-switcher-layout.test.mjs
diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
index ab947ee08..75da46850 100644
--- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
+++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx
@@ -1915,6 +1915,10 @@ export default function HomeSidebar({
+
+
+
+
{/* Navigation items grouped by section */}
@@ -1970,11 +1974,6 @@ export default function HomeSidebar({
{/* Footer */}
- {currentWorkspace?.workspace.source === 'cloud_projection' && (
-
-
-
- )}
{/* Models entry */}
diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
index fd5ff52bf..e8df0ced8 100644
--- a/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
+++ b/web/src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Copy, Loader2, Trash2, UserPlus, Users } from 'lucide-react';
+import {
+ Copy,
+ ExternalLink,
+ Loader2,
+ Trash2,
+ UserPlus,
+ Users,
+} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -27,12 +34,11 @@ import type {
WorkspaceMembership,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
-import { backendClient } from '@/app/infra/http';
+import { backendClient, systemInfo } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
-import WorkspaceSwitcher from './WorkspaceSwitcher';
interface WorkspaceSettingsPanelProps {
active: boolean;
@@ -77,6 +83,13 @@ export default function WorkspaceSettingsPanel({
!isCloudProjection && permissions.has('member.remove');
const canTransferOwner =
!isCloudProjection && permissions.has('owner.transfer');
+ const canManageCloudMembers =
+ isCloudProjection &&
+ (workspaceInfo?.membership.role === 'owner' ||
+ workspaceInfo?.membership.role === 'admin');
+ const cloudMembersURL = workspaceInfo
+ ? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}#workspace-members`
+ : '';
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -203,12 +216,20 @@ export default function WorkspaceSettingsPanel({
)}
-
{workspaceInfo && (
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
)}
+ {canManageCloudMembers && (
+
+ )}
diff --git a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx
index 0830e20d7..12440e4fd 100644
--- a/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx
+++ b/web/src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx
@@ -40,7 +40,7 @@ export default function WorkspaceSwitcher({
const workspaces = useWorkspaceBootstrap();
const isCloud = currentWorkspace?.workspace.source === 'cloud_projection';
- if (!currentWorkspace || (!isCloud && workspaces.length <= 1)) return null;
+ if (!currentWorkspace) return null;
const cloudPortalUrl = `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(currentWorkspace.workspace.uuid)}`;
diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx
index 487655ffa..fc91f7805 100644
--- a/web/src/app/home/layout.tsx
+++ b/web/src/app/home/layout.tsx
@@ -43,7 +43,6 @@ import {
PluginInstallProgressDialog,
} from '@/app/home/plugins/components/plugin-install-task';
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
-import WorkspaceSwitcher from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
@@ -265,7 +264,6 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
)}
-
diff --git a/web/tests/unit/workspace-switcher-layout.test.mjs b/web/tests/unit/workspace-switcher-layout.test.mjs
new file mode 100644
index 000000000..af88f6a9a
--- /dev/null
+++ b/web/tests/unit/workspace-switcher-layout.test.mjs
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
+const webRoot = path.resolve(currentDirectory, '../..');
+
+function readSource(relativePath) {
+ return fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
+}
+
+const homeLayoutSource = readSource('src/app/home/layout.tsx');
+const homeSidebarSource = readSource(
+ 'src/app/home/components/home-sidebar/HomeSidebar.tsx',
+);
+const workspaceSettingsPanelSource = readSource(
+ 'src/app/home/components/workspace-settings/WorkspaceSettingsPanel.tsx',
+);
+const workspaceSwitcherSource = readSource(
+ 'src/app/home/components/workspace-settings/WorkspaceSwitcher.tsx',
+);
+
+test('renders WorkspaceSwitcher only from HomeSidebar', () => {
+ assert.doesNotMatch(homeLayoutSource, / {
+ const headerEnd = homeSidebarSource.indexOf('');
+ const switcher = homeSidebarSource.indexOf('{t('sidebar.home')}",
+ );
+
+ assert.notEqual(headerEnd, -1);
+ assert.notEqual(switcher, -1);
+ assert.notEqual(contentStart, -1);
+ assert.notEqual(homeGroup, -1);
+ assert.ok(
+ headerEnd < switcher,
+ 'WorkspaceSwitcher must follow SidebarHeader',
+ );
+ assert.ok(
+ switcher < contentStart && switcher < homeGroup,
+ 'WorkspaceSwitcher must precede SidebarContent and the Home group',
+ );
+});
+
+test('shows WorkspaceSwitcher for a current Cloud or OSS workspace even when it is the only workspace', () => {
+ assert.match(
+ workspaceSwitcherSource,
+ /if \(!currentWorkspace\) return null;/,
+ );
+ assert.doesNotMatch(workspaceSwitcherSource, /workspaces\.length\s*<=\s*1/);
+ assert.doesNotMatch(
+ homeSidebarSource,
+ /currentWorkspace\?\.workspace\.source\s*===\s*'cloud_projection'[\s\S]{0,200} {
+ assert.match(workspaceSettingsPanelSource, /systemInfo\.cloud_service_url/);
+ assert.match(workspaceSettingsPanelSource, /#workspace-members/);
+ assert.match(workspaceSettingsPanelSource, /workspace\.inviteMember/);
+});