Skip to content

Commit 20101c5

Browse files
authored
improvement(app): show branded wordmark while loading (#8879)
* improvement(app): show branded wordmark while loading * fix(app): preload workspace data alongside branding
1 parent 47fb805 commit 20101c5

11 files changed

Lines changed: 135 additions & 53 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { cn, SimWordmark } from '@sim/emcn'
2+
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
3+
import { type BrandConfig, getBrandConfig } from '@/ee/whitelabeling'
4+
5+
interface ApplicationLoadingProps {
6+
brand?: BrandConfig
7+
fullScreen?: boolean
8+
}
9+
10+
/** Shared loading surface for app entry, shell prefetch, and workflow navigation. */
11+
export function ApplicationLoading({
12+
brand = getBrandConfig(),
13+
fullScreen = true,
14+
}: ApplicationLoadingProps) {
15+
const wordmarkUrl = brand.wordmarkUrl || brand.logoUrl
16+
17+
return (
18+
<div
19+
role='status'
20+
aria-label={`Loading ${brand.name}`}
21+
className={cn(
22+
'flex w-full items-center justify-center bg-[var(--bg)]',
23+
fullScreen ? 'desktop-title-bar-page' : 'h-full'
24+
)}
25+
>
26+
{fullScreen && <DesktopTitleBarLane />}
27+
{wordmarkUrl ? (
28+
<img src={wordmarkUrl} alt='' className='h-8 max-w-[240px] object-contain' />
29+
) : brand.isWhitelabeled ? (
30+
<span className='text-[var(--text-tertiary)] text-lg'>{brand.name}</span>
31+
) : (
32+
<SimWordmark size='loading' tone='brand-muted' />
33+
)}
34+
</div>
35+
)
36+
}

‎apps/sim/app/account/loading.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ApplicationLoading } from '@/app/_shell/application-loading'
2+
3+
export default function Loading() {
4+
return <ApplicationLoading />
5+
}

‎apps/sim/app/home/loading.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ApplicationLoading } from '@/app/_shell/application-loading'
2+
3+
export default function Loading() {
4+
return <ApplicationLoading />
5+
}

‎apps/sim/app/o/loading.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ApplicationLoading } from '@/app/_shell/application-loading'
2+
3+
export default function Loading() {
4+
return <ApplicationLoading />
5+
}

‎apps/sim/app/selfhost/loading.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ApplicationLoading } from '@/app/_shell/application-loading'
2+
3+
export default function Loading() {
4+
return <ApplicationLoading />
5+
}

‎apps/sim/app/workspace/[workspaceId]/layout.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory
44
import { emcnMock } from '@sim/testing/mocks/emcn.mock'
55
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
66
import { reactQueryMock } from '@sim/testing/mocks/react-query.mock'
7-
import { renderToStaticMarkup } from 'react-dom/server'
7+
import { renderToReadableStream } from 'react-dom/server'
88
import { beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
const {
@@ -178,7 +178,7 @@ describe('WorkspaceLayout host context', () => {
178178
children: <div>Workspace child</div>,
179179
params: Promise.resolve({ workspaceId: 'workspace-b' }),
180180
})
181-
renderToStaticMarkup(element)
181+
await new Response(await renderToReadableStream(element)).text()
182182

183183
expect(mockGetOrgWhitelabelSettings).toHaveBeenCalledWith('org-b')
184184
expect(mockGetOrgWhitelabelSettings).not.toHaveBeenCalledWith('org-a')
@@ -211,7 +211,7 @@ describe('WorkspaceLayout host context', () => {
211211
children: <div>Secret workspace child</div>,
212212
params: Promise.resolve({ workspaceId: 'workspace-denied' }),
213213
})
214-
const html = renderToStaticMarkup(element)
214+
const html = await new Response(await renderToReadableStream(element)).text()
215215

216216
expect(html).toContain('Workspace access denied')
217217
expect(html).not.toContain('Secret workspace child')
@@ -226,7 +226,7 @@ describe('WorkspaceLayout host context', () => {
226226
children: <div>Workspace child</div>,
227227
params: Promise.resolve({ workspaceId: 'workspace-b' }),
228228
})
229-
renderToStaticMarkup(element)
229+
await new Response(await renderToReadableStream(element)).text()
230230
return mockWorkspaceChrome.mock.calls[0][0].sidebar
231231
}
232232

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
1+
import { Suspense } from 'react'
2+
import { dehydrate, HydrationBoundary, type QueryClient } from '@tanstack/react-query'
23
import { cookies } from 'next/headers'
34
import { redirect } from 'next/navigation'
45
import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider'
6+
import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces'
57
import { getSession } from '@/lib/auth'
68
import { getActiveOrganizationId } from '@/lib/auth/session-response'
79
import { isChangelogEnabled } from '@/lib/changelog/feature-flag'
@@ -13,6 +15,7 @@ import {
1315
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
1416
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
1517
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
18+
import { ApplicationLoading } from '@/app/_shell/application-loading'
1619
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1720
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
1821
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
@@ -38,16 +41,33 @@ import { WorkspaceHostProvider } from '@/app/workspace/[workspaceId]/providers/w
3841
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
3942
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
4043
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
44+
import {
45+
getBrandConfig,
46+
mergeOrgBrandConfig,
47+
type OrganizationWhitelabelSettings,
48+
} from '@/ee/whitelabeling'
4149
import { BrandingProvider } from '@/ee/whitelabeling/components/branding-provider'
4250
import { getOrgWhitelabelSettings } from '@/ee/whitelabeling/org-branding'
4351

44-
export default async function WorkspaceLayout({
45-
children,
46-
params,
47-
}: {
52+
interface WorkspaceLayoutProps {
4853
children: React.ReactNode
4954
params: Promise<{ workspaceId: string }>
50-
}) {
55+
}
56+
57+
interface WorkspaceContentProps {
58+
children: React.ReactNode
59+
workspaceId: string
60+
session: NonNullable<Awaited<ReturnType<typeof getSession>>>
61+
queryClient: QueryClient
62+
hostContext: WorkspaceHostContext
63+
orgSettings: Promise<OrganizationWhitelabelSettings | null>
64+
}
65+
66+
interface WorkspaceLoadingProps {
67+
orgSettings: Promise<OrganizationWhitelabelSettings | null>
68+
}
69+
70+
export default async function WorkspaceLayout({ children, params }: WorkspaceLayoutProps) {
5171
const session = await getSession()
5272
if (!session?.user) {
5373
redirect('/login')
@@ -60,6 +80,38 @@ export default async function WorkspaceLayout({
6080
return <WorkspaceAccessDenied />
6181
}
6282

83+
const orgSettings = hostContext.hostOrganizationId
84+
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
85+
: Promise.resolve(null)
86+
87+
return (
88+
<Suspense fallback={<WorkspaceLoading orgSettings={orgSettings} />}>
89+
<WorkspaceContent
90+
workspaceId={workspaceId}
91+
session={session}
92+
queryClient={queryClient}
93+
hostContext={hostContext}
94+
orgSettings={orgSettings}
95+
>
96+
{children}
97+
</WorkspaceContent>
98+
</Suspense>
99+
)
100+
}
101+
102+
async function WorkspaceLoading({ orgSettings }: WorkspaceLoadingProps) {
103+
const brand = mergeOrgBrandConfig(await orgSettings, getBrandConfig())
104+
return <ApplicationLoading brand={brand} />
105+
}
106+
107+
async function WorkspaceContent({
108+
children,
109+
workspaceId,
110+
session,
111+
queryClient,
112+
hostContext,
113+
orgSettings,
114+
}: WorkspaceContentProps) {
63115
const activeOrganizationId = getActiveOrganizationId(session)
64116
const principal = {
65117
kind: 'session',
@@ -79,9 +131,7 @@ export default async function WorkspaceLayout({
79131
desktopExecutorRegistered,
80132
] = await Promise.all([
81133
cookies(),
82-
hostContext.hostOrganizationId
83-
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
84-
: Promise.resolve(null),
134+
orgSettings,
85135
prefetchWorkspaceSidebar(
86136
queryClient,
87137
workspaceId,

‎apps/sim/app/workspace/[workspaceId]/w/page.tsx‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,17 @@ import { Chip } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { ReactFlowProvider } from '@xyflow/react'
77
import { useParams, useRouter } from 'next/navigation'
8+
import { ApplicationLoading } from '@/app/_shell/application-loading'
89
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
910
import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
1011
import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
12+
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
1113
import { useWorkflows } from '@/hooks/queries/workflows'
1214

1315
const logger = createLogger('WorkflowsPage')
1416

15-
function Spinner() {
16-
return (
17-
<div
18-
className='size-[18px] animate-spin rounded-full'
19-
style={{
20-
background:
21-
'conic-gradient(from 0deg, var(--text-icon) 0deg 120deg, transparent 120deg 180deg, var(--text-icon) 180deg 300deg, transparent 300deg 360deg)',
22-
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
23-
WebkitMask:
24-
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
25-
}}
26-
/>
27-
)
28-
}
29-
3017
export default function WorkflowsPage() {
18+
const brand = useOrgBrandConfig()
3119
const router = useRouter()
3220
const params = useParams()
3321
const workspaceId = params.workspaceId as string
@@ -104,7 +92,7 @@ export default function WorkflowsPage() {
10492
)}
10593
</div>
10694
) : (
107-
<Spinner />
95+
<ApplicationLoading brand={brand} fullScreen={false} />
10896
)}
10997
</div>
11098
<ReactFlowProvider>

‎apps/sim/app/workspace/page.tsx‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
isUpgradeReason,
1717
UPGRADE_REASON_PARAM,
1818
} from '@/lib/billing/upgrade-reasons'
19+
import { ApplicationLoading } from '@/app/_shell/application-loading'
1920
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
2021
import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
2122

@@ -213,21 +214,7 @@ export default function WorkspacePage() {
213214
)
214215
}
215216

216-
return (
217-
<div className='desktop-title-bar-page flex w-full items-center justify-center'>
218-
<DesktopTitleBarLane />
219-
<div
220-
className='size-[18px] animate-spin rounded-full'
221-
style={{
222-
background:
223-
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
224-
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
225-
WebkitMask:
226-
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
227-
}}
228-
/>
229-
</div>
230-
)
217+
return <ApplicationLoading />
231218
}
232219

233220
async function handleWorkflowRedirect(

‎packages/emcn/src/components/sim-wordmark/sim-wordmark.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,22 @@ const WORDMARK_FILLS = {
77
'muted-inverse': 'var(--text-muted-inverse)',
88
} as const
99

10+
const WORDMARK_SIZES = {
11+
nav: { width: 37, height: 18, className: '-translate-y-[1.5px] h-[18px] w-auto' },
12+
tag: { width: 21, height: 10, className: 'h-[10px] w-auto' },
13+
loading: { width: 67, height: 32, className: 'h-8 w-auto' },
14+
} as const
15+
1016
export interface SimWordmarkProps {
11-
/** Navbar mark or compact mark sized for a 20px ChipTag. */
12-
size?: 'nav' | 'tag'
17+
/** Navbar, compact 20px ChipTag, or centered application loading mark. */
18+
size?: keyof typeof WORDMARK_SIZES
1319
/** Body ink, inherited foreground, muted brand ink, or light ink for inverse surfaces. */
1420
tone?: keyof typeof WORDMARK_FILLS
1521
}
1622

1723
/** Canonical Sim logotype, shared by browser and bundled desktop pages. */
1824
export function SimWordmark({ size = 'nav', tone = 'body' }: SimWordmarkProps) {
19-
const compact = size === 'tag'
20-
const width = compact ? 21 : 37
21-
const height = compact ? 10 : 18
25+
const { width, height, className } = WORDMARK_SIZES[size]
2226
const fill = WORDMARK_FILLS[tone]
2327

2428
return (
@@ -28,7 +32,7 @@ export function SimWordmark({ size = 'nav', tone = 'body' }: SimWordmarkProps) {
2832
height={height}
2933
fill='none'
3034
aria-hidden='true'
31-
className={compact ? 'h-[10px] w-auto' : '-translate-y-[1.5px] h-[18px] w-auto'}
35+
className={className}
3236
>
3337
<g fill={fill}>
3438
{WORDMARK_PATHS.map((d) => (

0 commit comments

Comments
 (0)