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/); +});