From 7c8be9cb9f1134c4b4b06158df7f6fdd96457f71 Mon Sep 17 00:00:00 2001 From: Dilan Induwara <153802063+Induwara04@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:42:35 +0530 Subject: [PATCH 1/2] Add Pluggable capability to AI workspace UI --- portals/ai-workspace/package.json | 2 + portals/ai-workspace/src/AIWorkspace.tsx | 131 ++++++++++++++++++ portals/ai-workspace/src/App.tsx | 26 +++- portals/ai-workspace/src/extensions.tsx | 34 +++++ portals/ai-workspace/src/index.ts | 11 ++ portals/ai-workspace/src/main.tsx | 118 +--------------- .../src/pages/appShell/AppSidebar.tsx | 26 ++++ .../src/pages/appShell/appShellMain.tsx | 14 +- 8 files changed, 249 insertions(+), 113 deletions(-) create mode 100644 portals/ai-workspace/src/AIWorkspace.tsx create mode 100644 portals/ai-workspace/src/extensions.tsx create mode 100644 portals/ai-workspace/src/index.ts diff --git a/portals/ai-workspace/package.json b/portals/ai-workspace/package.json index 0827f89cfe..2a87591da0 100644 --- a/portals/ai-workspace/package.json +++ b/portals/ai-workspace/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", "scripts": { "dev": "vite", "start": "vite", diff --git a/portals/ai-workspace/src/AIWorkspace.tsx b/portals/ai-workspace/src/AIWorkspace.tsx new file mode 100644 index 0000000000..a9cfed8838 --- /dev/null +++ b/portals/ai-workspace/src/AIWorkspace.tsx @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import React, { useEffect } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { IntlProvider } from "react-intl"; +import { + AcrylicOrangeTheme, + AcrylicPurpleTheme, + ClassicTheme, + HighContrastTheme, + OxygenUIThemeProvider, + Box, + LinearProgress, + Stack, + Typography, +} from "@wso2/oxygen-ui"; + +import App from "./App"; +import "./styles.css"; +import { AUTH_MODE } from "./config.env"; +import { BASE_PATH } from "./paths"; +import { BFFAuthProvider } from "./contexts/BFFAuthProvider"; +import { useAppAuth } from "./contexts/AppAuthContext"; +import BasicAuthLoginPage from "./pages/login/BasicAuthLoginPage"; +import type { AIWorkspaceExtension } from "./extensions"; + +function LoadingScreen({ message }: { message?: string }) { + return ( + + + {message && {message}} + + + + + + ); +} + +function OIDCRedirect() { + const { login } = useAppAuth(); + useEffect(() => { + void login(); + }, [login]); + return ; +} + +function AppGate({ + extensions, +}: { + extensions: readonly AIWorkspaceExtension[]; +}) { + const { isAuthenticated, isLoading } = useAppAuth(); + if (isLoading) return ; + if (!isAuthenticated) { + if (AUTH_MODE === "basic") { + return ( + { + const { pathname, search, hash } = window.location; + const route = pathname.startsWith(BASE_PATH) + ? pathname.slice(BASE_PATH.length) + : pathname; + window.location.replace( + route === "/login" || route === "/signin" + ? `${BASE_PATH}/` + : pathname + search + hash, + ); + }} + /> + ); + } + return ; + } + return ( + + + + ); +} + +export type AIWorkspaceProps = { + extensions?: readonly AIWorkspaceExtension[]; +}; + +export default function AIWorkspace({ extensions = [] }: AIWorkspaceProps) { + return ( + + + + + + + + ); +} diff --git a/portals/ai-workspace/src/App.tsx b/portals/ai-workspace/src/App.tsx index 048ad54ca0..9a00c71e28 100644 --- a/portals/ai-workspace/src/App.tsx +++ b/portals/ai-workspace/src/App.tsx @@ -85,6 +85,10 @@ import { ChoreoUserProvider } from './contexts/ChoreoUserContext'; import { useAppAuth } from './contexts/AppAuthContext'; import { Box, Button, Stack, Typography } from '@wso2/oxygen-ui'; import OoopsImage from './assets/images/Ooops.svg'; +import { + ExtensionsProvider, + type AIWorkspaceExtension, +} from './extensions'; /** * Only allow same-origin relative paths as return URLs to prevent open redirects. @@ -275,7 +279,17 @@ function WithPageBoundary({ children }: { children: React.ReactNode }) { return {children}; } -export default function App() { +export type AppProps = { + extensions?: readonly AIWorkspaceExtension[]; +}; + +function WorkspaceRoutes({ extensions = [] }: AppProps) { + const extensionRoutes = extensions.map((extension) => ( + {extension.element} + } /> + )); + return ( @@ -592,6 +606,7 @@ export default function App() { /> + {extensionRoutes} }> } /> + {extensionRoutes} @@ -812,3 +828,11 @@ export default function App() { ); } + +export default function App({ extensions = [] }: AppProps) { + return ( + + + + ); +} diff --git a/portals/ai-workspace/src/extensions.tsx b/portals/ai-workspace/src/extensions.tsx new file mode 100644 index 0000000000..f0760fcc4f --- /dev/null +++ b/portals/ai-workspace/src/extensions.tsx @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import React, { createContext, useContext } from 'react'; + +export type AIWorkspaceExtension = { + id: string; + path: string; + label: string; + icon?: React.ReactNode; + element: React.ReactNode; +}; + +const ExtensionsContext = createContext([]); + +export function ExtensionsProvider({ + extensions, + children, +}: { + extensions: readonly AIWorkspaceExtension[]; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useAIWorkspaceExtensions(): readonly AIWorkspaceExtension[] { + return useContext(ExtensionsContext); +} diff --git a/portals/ai-workspace/src/index.ts b/portals/ai-workspace/src/index.ts new file mode 100644 index 0000000000..12a53c9802 --- /dev/null +++ b/portals/ai-workspace/src/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +export { default as App } from './App'; +export type { AppProps } from './App'; +export { default as AIWorkspace } from './AIWorkspace'; +export type { AIWorkspaceProps } from './AIWorkspace'; +export type { AIWorkspaceExtension } from './extensions'; +export { BASE_PATH } from './paths'; diff --git a/portals/ai-workspace/src/main.tsx b/portals/ai-workspace/src/main.tsx index 1317a16940..d176b360e0 100644 --- a/portals/ai-workspace/src/main.tsx +++ b/portals/ai-workspace/src/main.tsx @@ -1,118 +1,14 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Licensed under the Apache License, Version 2.0. */ -import React, { useEffect } from 'react'; -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { IntlProvider } from 'react-intl'; -import { - AcrylicOrangeTheme, AcrylicPurpleTheme, ClassicTheme, - HighContrastTheme, OxygenUIThemeProvider, - Box, LinearProgress, Stack, Typography, -} from '@wso2/oxygen-ui'; +import React from "react"; +import { createRoot } from "react-dom/client"; +import AIWorkspace from "./AIWorkspace"; -import App from './App.tsx'; -import './styles.css'; -import { AUTH_MODE } from './config.env'; -import { BASE_PATH } from './paths'; -import { BFFAuthProvider } from './contexts/BFFAuthProvider'; -import { useAppAuth } from './contexts/AppAuthContext'; -import BasicAuthLoginPage from './pages/login/BasicAuthLoginPage'; - -// ── Loading screen ────────────────────────────────────────────────────────── - -function LoadingScreen({ message }: { message?: string }) { - return ( - - - {message && {message}} - - - - - - ); -} - -// ── OIDC redirect — the BFF owns the handshake; we just navigate to it ───────── - -function OIDCRedirect() { - const { login } = useAppAuth(); - useEffect(() => { void login(); }, [login]); - return ; -} - -// ── AppGate — decides login UX vs. the authenticated app ────────────────────── - -function AppGate() { - const { isAuthenticated, isLoading } = useAppAuth(); - - if (isLoading) { - return ; - } - - if (!isAuthenticated) { - if (AUTH_MODE === 'basic') { - // On success the BFF has set the session cookie; reload to re-hydrate while - // preserving the path the user originally requested (matching OIDC return - // behaviour). Only avoid pinning to the login route itself. - return { - // window.location is outside the router, so these paths carry the base path - // (BrowserRouter's basename) and the fallback has to add it back. - const { pathname, search, hash } = window.location; - const route = pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname; - const target = route === '/login' || route === '/signin' - ? `${BASE_PATH}/` - : pathname + search + hash; - window.location.replace(target); - }} />; - } - return ; - } - - return ( - - - - ); -} - -// ── Entry point ─────────────────────────────────────────────────────────────── - -const container = document.getElementById('root')!; -const root = createRoot(container); - -root.render( +createRoot(document.getElementById("root")!).render( - - - - - - - - + + , ); diff --git a/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx b/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx index 35d3091865..0946056ab0 100644 --- a/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx +++ b/portals/ai-workspace/src/pages/appShell/AppSidebar.tsx @@ -38,6 +38,7 @@ import { buildOrgPath, buildProjectPath } from '../../utils/projectRouting'; import QuickStartIntroPopup, { QS_INTRO_STORAGE_KEY, } from './QuickStartIntroPopup'; +import { useAIWorkspaceExtensions } from '../../extensions'; const navLinkStyle: React.CSSProperties = { textDecoration: 'none', @@ -64,6 +65,7 @@ export default function AppSidebar({ const [showIntro, setShowIntro] = useState( () => !localStorage.getItem(QS_INTRO_STORAGE_KEY) ); + const extensions = useAIWorkspaceExtensions(); const handleDismissIntro = () => { localStorage.setItem(QS_INTRO_STORAGE_KEY, '1'); @@ -127,6 +129,9 @@ export default function AppSidebar({ const settingsPath = currentProject ? buildProjectPath(currentOrganization, currentProject, '/settings') : orgSettingsPath; + const extensionPath = (path: string) => currentProject + ? buildProjectPath(currentOrganization, currentProject, `/${path}`) + : buildOrgPath(currentOrganization, `/${path}`); return ( )} + {extensions.length > 0 && ( + + + Cloud + + {extensions.map((extension) => ( + + + + {extension.icon ?? } + + {extension.label} + + + ))} + + )} diff --git a/portals/ai-workspace/src/pages/appShell/appShellMain.tsx b/portals/ai-workspace/src/pages/appShell/appShellMain.tsx index d5403b454c..3be1217dec 100644 --- a/portals/ai-workspace/src/pages/appShell/appShellMain.tsx +++ b/portals/ai-workspace/src/pages/appShell/appShellMain.tsx @@ -48,6 +48,7 @@ import { import { logger } from '../../utils/logger'; import { FormattedMessage } from 'react-intl'; import OoopsImage from '../../assets/images/Ooops.svg'; +import { useAIWorkspaceExtensions } from '../../extensions'; type SelectableOrg = { id: string; @@ -64,6 +65,7 @@ type SelectableProject = { export default function AppLayout(): JSX.Element { const navigate = useNavigate(); const location = useLocation(); + const extensions = useAIWorkspaceExtensions(); const { logout } = useAppAuth(); // [standalone] const { signOut } = useAuthContext(); @@ -142,6 +144,16 @@ export default function AppLayout(): JSX.Element { ? segments[3] ?? '' : segments[1] ?? ''; const tertiarySegment = isOrgScoped ? segments[4] ?? '' : segments[2] ?? ''; + const extensionSegment = primarySegment === 'projects' + ? tertiarySegment + : primarySegment; + const activeExtension = extensions.find( + (extension) => extension.path.split('/')[0] === extensionSegment + ); + if (activeExtension) { + shellActions.setActiveMenuItem(activeExtension.id); + return; + } if (!primarySegment || primarySegment === 'home') { shellActions.setActiveMenuItem('overview'); @@ -240,7 +252,7 @@ export default function AppLayout(): JSX.Element { if (primarySegment === 'settings') { shellActions.setActiveMenuItem('settings'); } - }, [location.pathname, shellActions, currentProject, currentOrganization]); + }, [location.pathname, shellActions, currentProject, currentOrganization, extensions]); useEffect(() => { const legalLinks = [ From 75ade98bda3326900597155bbace4630065a2f0e Mon Sep 17 00:00:00 2001 From: Dilan Induwara <153802063+Induwara04@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:09:15 +0530 Subject: [PATCH 2/2] Add licenses header --- portals/ai-workspace/src/AIWorkspace.tsx | 15 ++++++++++++++- portals/ai-workspace/src/extensions.tsx | 15 ++++++++++++++- portals/ai-workspace/src/main.tsx | 15 ++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/portals/ai-workspace/src/AIWorkspace.tsx b/portals/ai-workspace/src/AIWorkspace.tsx index a9cfed8838..a86fd5c56a 100644 --- a/portals/ai-workspace/src/AIWorkspace.tsx +++ b/portals/ai-workspace/src/AIWorkspace.tsx @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import React, { useEffect } from "react"; diff --git a/portals/ai-workspace/src/extensions.tsx b/portals/ai-workspace/src/extensions.tsx index f0760fcc4f..e7e7ccbbe3 100644 --- a/portals/ai-workspace/src/extensions.tsx +++ b/portals/ai-workspace/src/extensions.tsx @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import React, { createContext, useContext } from 'react'; diff --git a/portals/ai-workspace/src/main.tsx b/portals/ai-workspace/src/main.tsx index d176b360e0..7439da6f83 100644 --- a/portals/ai-workspace/src/main.tsx +++ b/portals/ai-workspace/src/main.tsx @@ -1,6 +1,19 @@ /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * Licensed under the Apache License, Version 2.0. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import React from "react";