diff --git a/web/oss/src/components/Layout/AuthUpgradeHost.tsx b/web/oss/src/components/Layout/AuthUpgradeHost.tsx
new file mode 100644
index 0000000000..0e8d69b41b
--- /dev/null
+++ b/web/oss/src/components/Layout/AuthUpgradeHost.tsx
@@ -0,0 +1,59 @@
+import {useCallback, useEffect} from "react"
+
+import {useAtom, useAtomValue, useSetAtom} from "jotai"
+
+import {
+ AUTH_UPGRADE_IDENTITIES_KEY,
+ AUTH_UPGRADE_ORG_KEY,
+ authUpgradeAtom,
+ resetAuthUpgradeState,
+} from "@/oss/state/org/authUpgrade"
+import {orgsAtom, selectedOrgIdAtom} from "@/oss/state/org/selectors/org"
+import {authFlowAtom} from "@/oss/state/session"
+
+import AuthUpgradeModal from "../Sidebar/components/AuthUpgradeModal"
+
+/**
+ * App-level host for the org auth-upgrade prompt. The org switcher (or any future entry point)
+ * only sets `authUpgradeAtom`; this component owns rendering and teardown so the flow no longer
+ * lives inside the sidebar selector.
+ */
+const AuthUpgradeHost = () => {
+ const [{open, orgId, detail}, setAuthUpgrade] = useAtom(authUpgradeAtom)
+ const selectedOrgId = useAtomValue(selectedOrgIdAtom)
+ const orgs = useAtomValue(orgsAtom)
+ const setAuthFlow = useSetAtom(authFlowAtom)
+
+ const organizationName = Array.isArray(orgs)
+ ? orgs.find((org) => org.id === orgId)?.name
+ : undefined
+
+ const close = useCallback(() => {
+ setAuthUpgrade(resetAuthUpgradeState())
+ setAuthFlow("authed")
+ if (typeof window !== "undefined") {
+ window.localStorage.removeItem(AUTH_UPGRADE_ORG_KEY)
+ window.localStorage.removeItem(AUTH_UPGRADE_IDENTITIES_KEY)
+ }
+ }, [setAuthFlow, setAuthUpgrade])
+
+ // The upgrade succeeded once the selected org becomes the org we prompted for.
+ useEffect(() => {
+ if (open && orgId && selectedOrgId && orgId === selectedOrgId) {
+ close()
+ }
+ }, [close, open, orgId, selectedOrgId])
+
+ if (!open) return null
+
+ return (
+
+ )
+}
+
+export default AuthUpgradeHost
diff --git a/web/oss/src/components/Layout/Layout.tsx b/web/oss/src/components/Layout/Layout.tsx
index 034e6fd16e..d066092e00 100644
--- a/web/oss/src/components/Layout/Layout.tsx
+++ b/web/oss/src/components/Layout/Layout.tsx
@@ -30,6 +30,7 @@ import type {SidebarView} from "../Sidebar/types"
import BreadcrumbContainer from "./assets/Breadcrumbs"
import {useStyles} from "./assets/styles"
+import AuthUpgradeHost from "./AuthUpgradeHost"
import ErrorFallback from "./ErrorFallback"
import PostHogThemeCapture from "./PostHogThemeCapture"
import {SidebarIsland} from "./SidebarIsland"
@@ -306,6 +307,7 @@ const AppWithVariants = memo(
return.
+
{project?.is_demo && (
<>
diff --git a/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx b/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx
index 1b47d02ab3..17b6b9f7d8 100644
--- a/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx
+++ b/web/oss/src/components/Sidebar/components/AuthUpgradeModal.tsx
@@ -21,21 +21,12 @@ import {getAgentaWebUrl} from "@/oss/lib/helpers/api"
import {getEffectiveAuthConfig} from "@/oss/lib/helpers/dynamicEnv"
import {isBackendAvailabilityIssue} from "@/oss/lib/helpers/errorHandler"
import {AuthErrorMsgType} from "@/oss/lib/Types"
+import type {AuthUpgradeDetail} from "@/oss/state/org/authUpgrade"
import {useProfileData} from "@/oss/state/profile"
-const {Text} = Typography
+export type {AuthUpgradeDetail}
-export interface AuthUpgradeDetail {
- message?: string
- required_methods?: string[]
- session_identities?: string[]
- user_identities?: string[]
- sso_providers?: {
- id: string
- slug: string
- third_party_id?: string
- }[]
-}
+const {Text} = Typography
interface AuthUpgradeModalProps {
open: boolean
diff --git a/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx b/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx
new file mode 100644
index 0000000000..d02ffa9f3f
--- /dev/null
+++ b/web/oss/src/components/Sidebar/components/ProjectOrgSwitcher/index.tsx
@@ -0,0 +1,335 @@
+import {memo, useCallback, useMemo, useState} from "react"
+
+import {InitialsAvatar} from "@agenta/ui"
+import {EnhancedModal} from "@agenta/ui/components/modal"
+import {
+ ArrowLeft,
+ ArrowsLeftRight,
+ CaretUpDown,
+ Check,
+ GearSix,
+ Plus,
+ SignOut,
+ X,
+} from "@phosphor-icons/react"
+import {Dropdown, Form, Input} from "antd"
+import clsx from "clsx"
+
+import {useProjectOrgSwitcher} from "../../hooks/useProjectOrgSwitcher"
+
+interface ProjectOrgSwitcherProps {
+ collapsed: boolean
+}
+
+type Panel = "projects" | "orgs"
+
+const ROW_CLASS =
+ "flex w-full items-center gap-2 h-8 px-2 rounded-md text-[13.5px] leading-none text-left cursor-pointer border-0 bg-transparent [font:inherit] text-[var(--ag-colorText)] hover:bg-[var(--ag-colorFillTertiary)] transition-colors"
+
+/** shrink-0 stops the capped scroll list from compressing rows instead of scrolling. */
+const ITEM_ROW_CLASS = "shrink-0"
+
+const CAPTION_CLASS =
+ "px-2 pt-1.5 pb-1 text-[11.5px] font-medium text-[var(--ag-colorTextTertiary)] truncate"
+
+const Row = ({
+ onClick,
+ className,
+ children,
+ title,
+}: {
+ onClick?: () => void
+ className?: string
+ children: React.ReactNode
+ title?: string
+}) => (
+
+)
+
+const ProjectOrgSwitcher = ({collapsed}: ProjectOrgSwitcherProps) => {
+ const {
+ currentOrg,
+ currentProject,
+ orgOptions,
+ projectsForOrg,
+ switchProject,
+ switchOrg,
+ goToOrgSettings,
+ confirmLogout,
+ createProject,
+ createOrg,
+ } = useProjectOrgSwitcher()
+
+ const [open, setOpen] = useState(false)
+ const [panel, setPanel] = useState
("projects")
+
+ const projectLabel = currentProject?.project_name || "Select project"
+ const orgLabel = currentOrg?.name || "Organization"
+
+ const handleOpenChange = useCallback((next: boolean) => {
+ setOpen(next)
+ if (!next) setPanel("projects")
+ }, [])
+
+ const close = useCallback(() => {
+ setOpen(false)
+ setPanel("projects")
+ }, [])
+
+ const projectPanel = useMemo(
+ () => (
+
+
Projects in {orgLabel}
+ {/* Cap the list at 3 item rows (h-8 each); the rest scrolls. */}
+
+ {projectsForOrg.map((proj) => {
+ const isActive =
+ proj.project_id === currentProject?.project_id &&
+ proj.workspace_id === currentProject?.workspace_id
+ return (
+ {
+ close()
+ if (!isActive) switchProject(proj)
+ }}
+ >
+
+ {proj.project_name}
+ {isActive && (
+
+ )}
+
+ )
+ })}
+
+
+
setPanel("orgs")}>
+
+ Switch organization
+
+
{
+ close()
+ createProject.setOpen(true)
+ }}
+ >
+
+ New project
+
+
{
+ close()
+ confirmLogout()
+ }}
+ >
+
+ Logout
+
+
+ ),
+ [
+ close,
+ confirmLogout,
+ createProject,
+ currentProject?.project_id,
+ currentProject?.workspace_id,
+ orgLabel,
+ projectsForOrg,
+ switchProject,
+ ],
+ )
+
+ const orgPanel = useMemo(
+ () => (
+
+
+
setPanel("projects")}
+ >
+
+ Projects
+
+
+
+
Organizations
+ {/* Cap the list at 3 item rows (h-8 each); the rest scrolls. */}
+
+ {orgOptions.map((org) => {
+ const isActive = org.id === currentOrg?.id
+ return (
+ {
+ close()
+ if (!isActive) void switchOrg(org.id)
+ }}
+ >
+
+ {org.name}
+ {isActive && (
+
+ )}
+
+ )
+ })}
+
+
+
{
+ close()
+ createOrg.setOpen(true)
+ }}
+ >
+
+ Create organization
+
+
{
+ close()
+ goToOrgSettings()
+ }}
+ >
+
+ Organization settings
+
+
+ ),
+ [close, createOrg, currentOrg?.id, goToOrgSettings, orgOptions, switchOrg],
+ )
+
+ return (
+
+
(
+ // Fixed width matching the expanded trigger (sidebar 236px − 14px wrapper padding).
+
+ {panel === "projects" ? projectPanel : orgPanel}
+
+ )}
+ >
+
+
+
+
{
+ createProject.setOpen(false)
+ createProject.form.resetFields()
+ }}
+ onOk={() => createProject.form.submit()}
+ confirmLoading={createProject.isPending}
+ destroyOnHidden
+ centered
+ >
+
+
+
+
+
+
+
{
+ createOrg.setOpen(false)
+ createOrg.form.resetFields()
+ }}
+ onOk={() => createOrg.form.submit()}
+ confirmLoading={createOrg.isPending}
+ destroyOnHidden
+ centered
+ >
+
+
+
+
+
+
+ )
+}
+
+export default memo(ProjectOrgSwitcher)
diff --git a/web/oss/src/components/Sidebar/components/SidebarLogo.tsx b/web/oss/src/components/Sidebar/components/SidebarLogo.tsx
new file mode 100644
index 0000000000..1358ba05aa
--- /dev/null
+++ b/web/oss/src/components/Sidebar/components/SidebarLogo.tsx
@@ -0,0 +1,38 @@
+import Image from "next/image"
+
+import {useAppTheme} from "@/oss/components/Layout/ThemeContextProvider"
+
+interface SidebarLogoProps {
+ collapsed: boolean
+}
+
+/** Brand header pinned at the top of the main sidebar: full wordmark expanded, symbol collapsed. */
+const SidebarLogo = ({collapsed}: SidebarLogoProps) => {
+ const {appTheme} = useAppTheme()
+ const isDark = appTheme === "dark"
+
+ const fullSrc = isDark
+ ? "/assets/logos/Agenta-logo-full-dark-accent.svg"
+ : "/assets/logos/Agenta-logo-full-light.svg"
+ const symbolSrc = isDark
+ ? "/assets/logos/Agenta-symbol-dark-accent.svg"
+ : "/assets/logos/Agenta-symbol-light.svg"
+
+ return (
+
+ {/* unoptimized: SVGs skip /_next/image, which rejects SVG without dangerouslyAllowSVG. */}
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default SidebarLogo
diff --git a/web/oss/src/components/Sidebar/engine/SidebarShell.tsx b/web/oss/src/components/Sidebar/engine/SidebarShell.tsx
index 2d5502731c..93721b6c0b 100644
--- a/web/oss/src/components/Sidebar/engine/SidebarShell.tsx
+++ b/web/oss/src/components/Sidebar/engine/SidebarShell.tsx
@@ -317,6 +317,7 @@ const SidebarShell: React.FC = ({
{renderSlot(scope.footer, collapsed, scope.lastPath)}
{bottomSections.map(renderSection)}
+ {renderSlot(scope.afterBottom, collapsed, scope.lastPath)}
diff --git a/web/oss/src/components/Sidebar/engine/types.ts b/web/oss/src/components/Sidebar/engine/types.ts
index 9341e1ad81..6d6099863c 100644
--- a/web/oss/src/components/Sidebar/engine/types.ts
+++ b/web/oss/src/components/Sidebar/engine/types.ts
@@ -67,6 +67,8 @@ export interface SidebarScope {
useSections: () => SidebarSection[]
header?: SidebarSlot
footer?: SidebarSlot
+ /** Pinned slot rendered below the bottom section — the very last element in the rail. */
+ afterBottom?: SidebarSlot
}
export interface SidebarShellProps {
diff --git a/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts b/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts
new file mode 100644
index 0000000000..ebfc294b4a
--- /dev/null
+++ b/web/oss/src/components/Sidebar/hooks/useProjectOrgSwitcher.ts
@@ -0,0 +1,300 @@
+import {useCallback, useMemo, useRef, useState} from "react"
+
+import {useMutation} from "@tanstack/react-query"
+import {App, Form} from "antd"
+import {useAtomValue, useSetAtom} from "jotai"
+import {useRouter} from "next/router"
+import Session from "supertokens-auth-react/recipe/session"
+
+import AlertPopup from "@/oss/components/AlertPopup/AlertPopup"
+import {useSession} from "@/oss/hooks/useSession"
+import useURL from "@/oss/hooks/useURL"
+import {buildProjectSwitchHref} from "@/oss/lib/navigation/projectSwitchHref"
+import type {OrgDetails} from "@/oss/lib/Types"
+import {checkOrganizationAccess} from "@/oss/services/organization/api"
+import type {ProjectsResponse} from "@/oss/services/project/types"
+import {appIdentifiersAtom} from "@/oss/state/appState"
+import {useOrgData} from "@/oss/state/org"
+import {
+ AUTH_UPGRADE_IDENTITIES_KEY,
+ AUTH_UPGRADE_ORG_KEY,
+ authUpgradeAtom,
+} from "@/oss/state/org/authUpgrade"
+import {
+ cacheWorkspaceOrgPair,
+ orgsAtom as organizationsAtom,
+ selectedOrgIdAtom,
+} from "@/oss/state/org/selectors/org"
+import {cacheLastUsedProjectId, useProjectData} from "@/oss/state/project"
+import {authFlowAtom} from "@/oss/state/session"
+import {settingsTabAtom} from "@/oss/state/settings"
+
+const formatErrorMessage = (detail: any, fallback: string) => {
+ if (typeof detail === "string") return detail
+ if (detail && typeof detail.message === "string") return detail.message
+ return fallback
+}
+
+export interface SwitcherOrg {
+ id: string
+ name: string
+}
+
+/**
+ * Data + actions for the combined project/org switcher. Consolidates the view / switch / create /
+ * logout logic that previously lived across `ListOfOrgs` and `ListOfProjects`, minus the management
+ * actions that now live in Settings.
+ */
+export const useProjectOrgSwitcher = () => {
+ const router = useRouter()
+ const {message} = App.useApp()
+ const {logout} = useSession()
+ const {projectURL} = useURL()
+
+ const {selectedOrg, orgs, changeSelectedOrg, refetch} = useOrgData()
+ const selectedOrgId = useAtomValue(selectedOrgIdAtom)
+ const organizationList = useAtomValue(organizationsAtom)
+ const {project, projects, refetch: refetchProjects} = useProjectData()
+ const settingsTab = useAtomValue(settingsTabAtom)
+ const {workspaceId: currentWorkspaceId} = useAtomValue(appIdentifiersAtom)
+ const setAuthFlow = useSetAtom(authFlowAtom)
+ const setAuthUpgrade = useSetAtom(authUpgradeAtom)
+
+ const effectiveSelectedId = selectedOrg?.id || selectedOrgId || orgs?.[0]?.id || null
+
+ const safeOrganizationList = useMemo(
+ () => (Array.isArray(organizationList) ? organizationList : []),
+ [organizationList],
+ )
+ const currentOrg = useMemo(() => {
+ const match =
+ safeOrganizationList.find((org) => org.id === effectiveSelectedId) ||
+ orgs.find((org) => org.id === effectiveSelectedId)
+ if (!match?.id) return null
+ return {id: match.id, name: (match.name as string) || "Organization"}
+ }, [safeOrganizationList, orgs, effectiveSelectedId])
+
+ const orgOptions = useMemo(
+ () => orgs.filter((org) => org.id).map((org) => ({id: org.id, name: org.name as string})),
+ [orgs],
+ )
+
+ const projectsByOrganization = useMemo(() => {
+ const map = new Map()
+ orgs.forEach((org) => map.set(org.id, []))
+ projects.forEach((proj) => {
+ if (!proj) return
+ const organizationId =
+ proj.organization_id ||
+ orgs.find(
+ (org) =>
+ (org as Partial).default_workspace?.id === proj.workspace_id,
+ )?.id
+ if (!organizationId) return
+ if (!map.has(organizationId)) map.set(organizationId, [])
+ map.get(organizationId)?.push(proj)
+ })
+ return map
+ }, [orgs, projects])
+
+ const projectsForOrg = useMemo(
+ () => (effectiveSelectedId ? (projectsByOrganization.get(effectiveSelectedId) ?? []) : []),
+ [projectsByOrganization, effectiveSelectedId],
+ )
+
+ // ── Create project ─────────────────────────────────────────────────────
+ const [createProjectOpen, setCreateProjectOpen] = useState(false)
+ const [createProjectForm] = Form.useForm<{name: string}>()
+
+ const navigateToProject = useCallback(
+ (workspaceId: string, projectId: string, organizationId?: string | null) => {
+ if (!workspaceId || !projectId) return
+ cacheLastUsedProjectId(workspaceId, projectId)
+ if (organizationId) cacheWorkspaceOrgPair(workspaceId, organizationId)
+ const href = buildProjectSwitchHref({
+ workspaceId,
+ projectId,
+ currentAsPath: router.asPath,
+ settingsTab,
+ queryTab: router.query.tab,
+ })
+ void router.push(href)
+ },
+ [router, settingsTab],
+ )
+
+ const createProjectMutation = useMutation({
+ mutationFn: async ({name}: {name: string}) => {
+ const {createProject} = await import("@/oss/services/project")
+ return createProject({name: name.trim()}, currentWorkspaceId ?? undefined)
+ },
+ onSuccess: (createdProject) => {
+ message.success("Project created")
+ createProjectForm.resetFields()
+ setCreateProjectOpen(false)
+ // Only a real workspace id routes correctly; org id would build /w//... .
+ const workspaceKey = createdProject?.workspace_id || currentWorkspaceId || ""
+ if (workspaceKey && createdProject?.project_id) {
+ navigateToProject(
+ workspaceKey,
+ createdProject.project_id,
+ createdProject.organization_id ?? effectiveSelectedId,
+ )
+ }
+ void refetchProjects()
+ },
+ onError: (error: any) => {
+ const detail = error?.response?.data?.detail || error?.message
+ message.error(formatErrorMessage(detail, "Unable to create project"))
+ },
+ })
+
+ // ── Create organization ────────────────────────────────────────────────
+ const [createOrgOpen, setCreateOrgOpen] = useState(false)
+ const [createOrgForm] = Form.useForm<{name: string}>()
+
+ const createOrgMutation = useMutation({
+ mutationFn: async (values: {name: string}) => {
+ const {createOrganization} = await import("@/oss/services/organization/api")
+ return createOrganization({name: values.name.trim()})
+ },
+ onSuccess: async (createdOrg) => {
+ message.success("Organization created")
+ createOrgForm.resetFields()
+ setCreateOrgOpen(false)
+ await refetch()
+ if (createdOrg?.id) await changeSelectedOrg(createdOrg.id)
+ },
+ onError: (error: any) => {
+ const detail = error?.response?.data?.detail || error?.message
+ message.error(formatErrorMessage(detail, "Unable to create organization"))
+ },
+ })
+
+ // ── Switch project / org ───────────────────────────────────────────────
+ const switchProject = useCallback(
+ (proj: ProjectsResponse) => {
+ navigateToProject(
+ proj.workspace_id || "",
+ proj.project_id,
+ proj.organization_id ?? effectiveSelectedId,
+ )
+ },
+ [navigateToProject, effectiveSelectedId],
+ )
+
+ const lastDomainDeniedOrgIdRef = useRef(null)
+ const lastDomainDeniedAtRef = useRef(0)
+
+ const switchOrg = useCallback(
+ async (organizationId: string) => {
+ if (!organizationId || organizationId === effectiveSelectedId) return
+ try {
+ const result = await checkOrganizationAccess(organizationId)
+ if (result.ok) {
+ await changeSelectedOrg(organizationId)
+ return
+ }
+ const detail = result.response?.data?.detail
+ if (
+ detail?.error === "AUTH_UPGRADE_REQUIRED" ||
+ detail?.error === "AUTH_SSO_DENIED"
+ ) {
+ setAuthFlow("authing")
+ // Write both keys before opening the modal so the SSO redirect can't
+ // fire ahead of the identities write.
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(AUTH_UPGRADE_ORG_KEY, organizationId)
+ try {
+ const payload = await Session.getAccessTokenPayloadSecurely()
+ const sessionIdentities =
+ payload?.session_identities || payload?.sessionIdentities || []
+ window.localStorage.setItem(
+ AUTH_UPGRADE_IDENTITIES_KEY,
+ JSON.stringify(sessionIdentities),
+ )
+ } catch {
+ // identities are optional for the redirect
+ }
+ }
+ setAuthUpgrade({open: true, orgId: organizationId, detail})
+ return
+ }
+ if (detail?.error === "AUTH_DOMAIN_DENIED") {
+ const content =
+ typeof detail?.message === "string"
+ ? detail.message
+ : "Your email domain is not allowed for this organization."
+ const now = Date.now()
+ const recentlyNotified =
+ lastDomainDeniedOrgIdRef.current === organizationId &&
+ now - lastDomainDeniedAtRef.current < 2000
+ if (!recentlyNotified) {
+ lastDomainDeniedOrgIdRef.current = organizationId
+ lastDomainDeniedAtRef.current = now
+ message.error({content, key: "domain-denied"})
+ }
+ return
+ }
+ message.error(
+ formatErrorMessage(
+ result.response?.data?.detail || result.response?.statusText,
+ "Unable to switch organization",
+ ),
+ )
+ } catch (error) {
+ message.error("Unable to switch organization")
+ }
+ },
+ [changeSelectedOrg, effectiveSelectedId, message, setAuthFlow, setAuthUpgrade],
+ )
+
+ const goToOrgSettings = useCallback(() => {
+ if (!projectURL) return
+ void router.push(`${projectURL}/settings?tab=organizationGeneral`)
+ }, [projectURL, router])
+
+ const confirmLogout = useCallback(() => {
+ AlertPopup({
+ title: "Logout",
+ message: "Are you sure you want to logout?",
+ centered: true,
+ onOk: logout,
+ })
+ }, [logout])
+
+ const createProject = useMemo(
+ () => ({
+ open: createProjectOpen,
+ setOpen: setCreateProjectOpen,
+ form: createProjectForm,
+ submit: (values: {name: string}) => createProjectMutation.mutate(values),
+ isPending: createProjectMutation.isPending,
+ }),
+ [createProjectOpen, createProjectForm, createProjectMutation],
+ )
+
+ const createOrg = useMemo(
+ () => ({
+ open: createOrgOpen,
+ setOpen: setCreateOrgOpen,
+ form: createOrgForm,
+ submit: (values: {name: string}) => createOrgMutation.mutate(values),
+ isPending: createOrgMutation.isPending,
+ }),
+ [createOrgOpen, createOrgForm, createOrgMutation],
+ )
+
+ return {
+ currentOrg,
+ currentProject: project ?? null,
+ orgOptions,
+ projectsForOrg,
+ switchProject,
+ switchOrg,
+ goToOrgSettings,
+ confirmLogout,
+ createProject,
+ createOrg,
+ }
+}
diff --git a/web/oss/src/components/Sidebar/scopes/bottomSection.tsx b/web/oss/src/components/Sidebar/scopes/bottomSection.tsx
index e0d6a26f05..cd09ae4e8f 100644
--- a/web/oss/src/components/Sidebar/scopes/bottomSection.tsx
+++ b/web/oss/src/components/Sidebar/scopes/bottomSection.tsx
@@ -69,17 +69,21 @@ export const useSidebarBottomSection = ({
[hasProjectURL, projectURL],
)
+ const inviteItem = useMemo(
+ () => ({
+ key: "invite-teammate-link",
+ title: "Invite Teammate",
+ link: `${projectURL}/settings?tab=workspace&inviteModal=open`,
+ icon: ,
+ tooltip: "Invite Teammate",
+ isHidden: !doesSessionExist || !selectedOrg || !canInviteMembers,
+ disabled: !hasProjectURL,
+ }),
+ [canInviteMembers, doesSessionExist, hasProjectURL, projectURL, selectedOrg],
+ )
+
const sharedItems = useMemo(
() => [
- {
- key: "invite-teammate-link",
- title: "Invite Teammate",
- link: `${projectURL}/settings?tab=workspace&inviteModal=open`,
- icon: ,
- tooltip: "Invite Teammate",
- isHidden: !doesSessionExist || !selectedOrg || !canInviteMembers,
- disabled: !hasProjectURL,
- },
{
key: "get-started-guide-link",
title: "Get Started Guide",
@@ -92,13 +96,6 @@ export const useSidebarBottomSection = ({
isHidden: !SHOW_GET_STARTED_GUIDE || !doesSessionExist,
onClick: handleOpenWidget,
},
- {
- key: "support-chat-link",
- title: `Live Chat Support: ${isVisible ? "On" : "Off"}`,
- icon: ,
- isHidden: !isDemo() || !isCrispEnabled,
- onClick: handleToggleSupport,
- },
{
key: "help-docs-link",
title: "Help & Docs",
@@ -122,37 +119,38 @@ export const useSidebarBottomSection = ({
title: "Slack Support",
link: "https://join.slack.com/t/agenta-hq/shared_invite/zt-37pnbp5s6-mbBrPL863d_oLB61GSNFjw",
icon: ,
- divider: true,
},
{
key: "book-call",
title: "Book a call",
link: "https://cal.com/mahmoud-mabrouk-ogzgey/demo",
icon: ,
+ // Live Chat relocates here from a standalone row; keep the divider only
+ // when it will actually render (demo + Crisp), else it dangles.
+ divider: isDemo() && isCrispEnabled,
+ },
+ {
+ key: "support-chat-link",
+ title: `Live Chat Support: ${isVisible ? "On" : "Off"}`,
+ icon: ,
+ isHidden: !isDemo() || !isCrispEnabled,
+ onClick: handleToggleSupport,
},
],
},
],
- [
- canInviteMembers,
- doesSessionExist,
- handleOpenWidget,
- handleToggleSupport,
- hasProjectURL,
- isCrispEnabled,
- isVisible,
- projectURL,
- selectedOrg,
- ],
+ [doesSessionExist, handleOpenWidget, handleToggleSupport, isCrispEnabled, isVisible],
)
return useMemo(
() => ({
key: "bottom",
- items: includeSettingsLink ? [settingsLink, ...sharedItems] : sharedItems,
+ items: includeSettingsLink
+ ? [settingsLink, inviteItem, ...sharedItems]
+ : [inviteItem, ...sharedItems],
placement: "bottom",
mode: "vertical",
}),
- [includeSettingsLink, settingsLink, sharedItems],
+ [includeSettingsLink, settingsLink, inviteItem, sharedItems],
)
}
diff --git a/web/oss/src/components/Sidebar/scopes/mainScope.tsx b/web/oss/src/components/Sidebar/scopes/mainScope.tsx
index b539ebe947..81378ff18e 100644
--- a/web/oss/src/components/Sidebar/scopes/mainScope.tsx
+++ b/web/oss/src/components/Sidebar/scopes/mainScope.tsx
@@ -1,12 +1,12 @@
import {useMemo} from "react"
-import {Divider} from "antd"
import {useAtomValue} from "jotai"
import SidePanelSubscriptionInfo from "@/oss/components/SidePanel/Subscription"
import {homeNavHighlightedAtom} from "@/oss/state/onboarding"
-import ListOfOrgs from "../components/ListOfOrgs"
+import ProjectOrgSwitcher from "../components/ProjectOrgSwitcher"
+import SidebarLogo from "../components/SidebarLogo"
import type {
SidebarScope,
SidebarSection,
@@ -18,12 +18,7 @@ import {useSidebarConfig} from "../hooks/useSidebarConfig"
import {useSidebarBottomSection} from "./bottomSection"
import {HOME_SIDEBAR_KEY, MAIN_SIDEBAR_SCOPE_ID} from "./constants"
-const MainSidebarHeader = ({collapsed}: SidebarSlotContext) => (
- <>
-
-
- >
-)
+const MainSidebarHeader = ({collapsed}: SidebarSlotContext) =>
const MainSidebarFooter = ({collapsed}: SidebarSlotContext) =>
collapsed ? null : (
@@ -32,6 +27,10 @@ const MainSidebarFooter = ({collapsed}: SidebarSlotContext) =>
)
+const MainSidebarAfterBottom = ({collapsed}: SidebarSlotContext) => (
+
+)
+
// During onboarding the route is the ephemeral playground, but Home IS the surface — pin it selected.
const useMainSidebarSelection = (): SidebarSelection => {
const highlightHome = useAtomValue(homeNavHighlightedAtom)
@@ -66,4 +65,5 @@ export const mainSidebarScope: SidebarScope = {
useSections: useMainSidebarSections,
header: MainSidebarHeader,
footer: MainSidebarFooter,
+ afterBottom: MainSidebarAfterBottom,
}
diff --git a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
index 930ac298db..02043de024 100644
--- a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
+++ b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
@@ -16,7 +16,6 @@ import {
Vault,
Wrench,
} from "@phosphor-icons/react"
-import {Divider} from "antd"
import {useAtom} from "jotai"
import {
@@ -31,7 +30,7 @@ import {useSettingsAccess} from "@/oss/components/pages/settings/hooks/useSettin
import {useQueryParam} from "@/oss/hooks/useQuery"
import {settingsTabAtom} from "@/oss/state/settings"
-import ListOfOrgs from "../components/ListOfOrgs"
+import ProjectOrgSwitcher from "../components/ProjectOrgSwitcher"
import SidebarBackButton from "../components/SidebarBackButton"
import type {
SidebarConfig,
@@ -160,19 +159,18 @@ const useSettingsSidebarSections = (): SidebarSection[] => {
}
const SettingsSidebarHeader = ({collapsed, lastPath}: SidebarSlotContext) => (
- <>
-
-
-
-
-
-
- >
+
+
+
+)
+
+const SettingsSidebarAfterBottom = ({collapsed}: SidebarSlotContext) => (
+
)
export const createSettingsSidebarScope = ({lastPath}: SettingsScopeOptions): SidebarScope => ({
@@ -181,4 +179,5 @@ export const createSettingsSidebarScope = ({lastPath}: SettingsScopeOptions): Si
useSelection: useSettingsSidebarSelection,
useSections: useSettingsSidebarSections,
header: SettingsSidebarHeader,
+ afterBottom: SettingsSidebarAfterBottom,
})
diff --git a/web/oss/src/components/pages/settings/Organization/General.tsx b/web/oss/src/components/pages/settings/Organization/General.tsx
index d14fb6b762..52775053da 100644
--- a/web/oss/src/components/pages/settings/Organization/General.tsx
+++ b/web/oss/src/components/pages/settings/Organization/General.tsx
@@ -2,7 +2,7 @@ import {useCallback, useMemo, useState} from "react"
import {InitialsAvatar} from "@agenta/ui"
import {EnhancedModal} from "@agenta/ui/components/modal"
-import {ArrowsLeftRight, Trash} from "@phosphor-icons/react"
+import {ArrowsLeftRight, CopyIcon, Trash} from "@phosphor-icons/react"
import {useMutation} from "@tanstack/react-query"
import {App, Button, Form, Input, Select, Typography} from "antd"
import clsx from "clsx"
@@ -131,6 +131,20 @@ const OrganizationGeneral = () => {
},
})
+ const copyOrganizationId = useCallback(async () => {
+ if (!organizationId) return
+ if (typeof navigator === "undefined" || !navigator?.clipboard) {
+ message.error("Clipboard not supported")
+ return
+ }
+ try {
+ await navigator.clipboard.writeText(organizationId)
+ message.success("Organization ID copied")
+ } catch {
+ message.error("Failed to copy organization ID")
+ }
+ }, [message, organizationId])
+
const handleDelete = useCallback(async () => {
if (!organizationId || !isDeleteNameMatch) return
@@ -199,6 +213,22 @@ const OrganizationGeneral = () => {
+
+
+
+ }
+ disabled={!organizationId}
+ onClick={copyOrganizationId}
+ >
+ Copy
+
+
+
+
{
await queryClient.invalidateQueries({queryKey: ["projects"]})
}, [queryClient])
+ const copyProjectId = useCallback(
+ async (projectId: string) => {
+ if (typeof navigator === "undefined" || !navigator?.clipboard) {
+ message.error("Clipboard not supported")
+ return
+ }
+ try {
+ await navigator.clipboard.writeText(projectId)
+ message.success("Project ID copied")
+ } catch {
+ message.error("Failed to copy project ID")
+ }
+ },
+ [message],
+ )
+
const createMutation = useMutation({
mutationFn: (payload: ProjectFormValues) => createProject(payload),
onSuccess: () => {
@@ -186,9 +202,20 @@ const ProjectsSettings = () => {
Default
)}
-
- {record.project_id}
-
+
+
+ {record.project_id}
+
+
+ }
+ onClick={() => copyProjectId(record.project_id)}
+ />
+
+
),
},
@@ -256,6 +283,7 @@ const ProjectsSettings = () => {
],
[
canDeleteProjects,
+ copyProjectId,
defaultMutation.isPending,
defaultMutation.variables,
deleteMutation.isPending,
diff --git a/web/oss/src/state/org/authUpgrade.ts b/web/oss/src/state/org/authUpgrade.ts
new file mode 100644
index 0000000000..a89e61b268
--- /dev/null
+++ b/web/oss/src/state/org/authUpgrade.ts
@@ -0,0 +1,36 @@
+import {atom} from "jotai"
+
+/** Auth-upgrade payload from the org-access check. Lives here (state, not a component) so
+ * consumers of `authUpgradeAtom` don't pull a Sidebar component into their type graph. */
+export interface AuthUpgradeDetail {
+ message?: string
+ required_methods?: string[]
+ session_identities?: string[]
+ user_identities?: string[]
+ sso_providers?: {
+ id: string
+ slug: string
+ third_party_id?: string
+ }[]
+}
+
+/** localStorage keys shared by the switcher (writes) and the auth-redirect flow (reads). */
+export const AUTH_UPGRADE_ORG_KEY = "authUpgradeOrgId"
+export const AUTH_UPGRADE_IDENTITIES_KEY = "authUpgradeSessionIdentities"
+
+export interface AuthUpgradeState {
+ open: boolean
+ orgId: string | null
+ detail: AuthUpgradeDetail | null
+}
+
+const INITIAL_AUTH_UPGRADE_STATE: AuthUpgradeState = {open: false, orgId: null, detail: null}
+
+/**
+ * App-level auth-upgrade prompt state. Any entry point that switches organizations sets this
+ * atom on an `AUTH_UPGRADE_REQUIRED` / `AUTH_SSO_DENIED` response; a single app-level host
+ * (`AuthUpgradeHost`) renders the modal. Keeps the flow out of the sidebar selector.
+ */
+export const authUpgradeAtom = atom(INITIAL_AUTH_UPGRADE_STATE)
+
+export const resetAuthUpgradeState = (): AuthUpgradeState => ({...INITIAL_AUTH_UPGRADE_STATE})