@sanity/sdk-react 3.4.0 → 3.5.0-next.20260922190712

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import {
2
+ getOAuthTokensState,
3
+ type OAuthTokens,
4
+ refreshOAuthTokens,
5
+ revokeOAuthTokens,
6
+ type SanityInstance,
7
+ } from '@sanity/sdk'
8
+
9
+ import {createCallbackHook} from '../helpers/createCallbackHook'
10
+ import {createStateSourceHook} from '../helpers/createStateSourceHook'
11
+
12
+ /**
13
+ * The current OAuth token state, plus actions to refresh and revoke it.
14
+ *
15
+ * @public
16
+ */
17
+ export interface UseOAuthTokensResult {
18
+ /**
19
+ * The stored OAuth tokens, or `null` when not logged in via OAuth. The
20
+ * refresh token is omitted — core retains it internally for `refresh`.
21
+ */
22
+ tokens: Omit<OAuthTokens, 'refreshToken'> | null
23
+ /**
24
+ * Returns whether the access token has expired, comparing the latest stored
25
+ * `expiresAt` against the current time at the moment it is called. Both the
26
+ * tokens and the clock are read at call time, so a reference captured in an
27
+ * earlier render stays accurate after a `refresh`. Reading the clock does not
28
+ * trigger a re-render, so call this in an event handler or effect rather than
29
+ * during render. Returns `false` when there are no tokens.
30
+ */
31
+ isExpired: () => boolean
32
+ /**
33
+ * Refresh via the OAuth `refresh_token` grant. When there is no refresh token,
34
+ * core clears the stored tokens, logs the user out, and this resolves `null`.
35
+ * Rejects on transient failures (network, 5xx, 408, 429), leaving tokens
36
+ * unchanged so the call can be retried. Also rejects when the server rejects
37
+ * the refresh token itself (other 4xx); core clears the tokens and logs out
38
+ * first, so check `tokens` before retrying.
39
+ */
40
+ refresh: () => Promise<Omit<OAuthTokens, 'refreshToken'> | null>
41
+ /** Revoke the tokens at the OAuth server, clear them locally, and log out. */
42
+ revoke: () => Promise<void>
43
+ }
44
+
45
+ const useOAuthTokensState = createStateSourceHook(getOAuthTokensState)
46
+ const useRefreshOAuthTokens = createCallbackHook(refreshOAuthTokens)
47
+ const useRevokeOAuthTokens = createCallbackHook(revokeOAuthTokens)
48
+
49
+ // Reads core's state source directly rather than the render-time `tokens`
50
+ // snapshot, so a reference captured in an earlier render (e.g. in an event
51
+ // handler that awaits `refresh()`) still reflects the latest tokens.
52
+ const useIsOAuthTokenExpired = createCallbackHook((instance: SanityInstance): boolean => {
53
+ const tokens = getOAuthTokensState(instance).getCurrent()
54
+ return tokens ? tokens.expiresAt.getTime() <= Date.now() : false
55
+ })
56
+
57
+ /**
58
+ * A React hook that exposes the stored OAuth token state along with `refresh`
59
+ * and `revoke` actions.
60
+ *
61
+ * @remarks
62
+ * The token view is a synchronous read over core's token state source, so the
63
+ * hook re-renders whenever tokens change — including changes made in another
64
+ * tab, which core propagates via `storage` events.
65
+ *
66
+ * @returns The current {@link UseOAuthTokensResult}
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * function TokenStatus() {
71
+ * const {tokens, isExpired, refresh, revoke} = useOAuthTokens()
72
+ *
73
+ * if (!tokens) return <div>Not signed in</div>
74
+ *
75
+ * const handleRefresh = async () => {
76
+ * if (!isExpired()) return
77
+ * try {
78
+ * await refresh()
79
+ * } catch {
80
+ * // Transient failure (tokens unchanged, retry later) or the refresh
81
+ * // token was rejected (tokens now null, user is logged out).
82
+ * }
83
+ * }
84
+ *
85
+ * return (
86
+ * <div>
87
+ * <p>Expires at {tokens.expiresAt.toLocaleTimeString()}</p>
88
+ * <button onClick={handleRefresh}>Refresh if expired</button>
89
+ * <button onClick={() => revoke()}>Revoke tokens</button>
90
+ * </div>
91
+ * )
92
+ * }
93
+ * ```
94
+ *
95
+ * @public
96
+ */
97
+ export function useOAuthTokens(): UseOAuthTokensResult {
98
+ return {
99
+ tokens: useOAuthTokensState(),
100
+ isExpired: useIsOAuthTokenExpired(),
101
+ refresh: useRefreshOAuthTokens(),
102
+ revoke: useRevokeOAuthTokens(),
103
+ }
104
+ }
@@ -1,5 +1,16 @@
1
+ import {
2
+ type Application,
3
+ type ApplicationInclude,
4
+ type Installation,
5
+ type InstallationInclude,
6
+ } from '@sanity/sdk'
1
7
  import {installMessageBus, resetMessageBus} from '@sanity/sdk/_internal'
2
- import {type MessageBusHost, TopicError, type ValueOf} from '@sanity/sdk/dashboard'
8
+ import {
9
+ type LocalApplication,
10
+ type MessageBusHost,
11
+ TopicError,
12
+ type ValueOf,
13
+ } from '@sanity/sdk/dashboard'
3
14
  import {Suspense} from 'react'
4
15
  import {ErrorBoundary} from 'react-error-boundary'
5
16
  import {afterEach, beforeEach, describe, expect, expectTypeOf, it, vi} from 'vitest'
@@ -62,7 +73,7 @@ const application = {
62
73
  title: 'Summary',
63
74
  version: '1',
64
75
  moduleId: 'views/summary',
65
- metadata: null,
76
+ metadata: {size: 'small'},
66
77
  },
67
78
  {
68
79
  id: 'asset-source-1',
@@ -84,7 +95,7 @@ const application = {
84
95
  },
85
96
  ],
86
97
  },
87
- }
98
+ } satisfies Application<ApplicationInclude>
88
99
 
89
100
  const nonFederatedApplication = {
90
101
  ...application,
@@ -95,7 +106,7 @@ const nonFederatedApplication = {
95
106
  title: 'Legacy',
96
107
  isSingleton: false,
97
108
  config: {},
98
- }
109
+ } satisfies Application<ApplicationInclude>
99
110
 
100
111
  const nonSingletonApplication = {
101
112
  ...application,
@@ -105,7 +116,7 @@ const nonSingletonApplication = {
105
116
  slug: 'canvas',
106
117
  title: 'Canvas',
107
118
  isSingleton: false,
108
- }
119
+ } satisfies Application<ApplicationInclude>
109
120
 
110
121
  const externalApplication = {
111
122
  ...application,
@@ -113,7 +124,57 @@ const externalApplication = {
113
124
  name: 'external',
114
125
  slug: null,
115
126
  externalUrl: 'https://apps.example.com/external/index.html',
116
- }
127
+ } satisfies Application<ApplicationInclude>
128
+
129
+ // The workbench synthesises a dev-server app into the deployed shape and marks it with `local`.
130
+ const localApplication = {
131
+ ...application,
132
+ id: 'application-5',
133
+ name: 'dev',
134
+ slug: null,
135
+ isSingleton: false,
136
+ externalUrl: 'http://localhost:3333',
137
+ organizationId: 'local',
138
+ local: {host: 'localhost', port: 3333},
139
+ } satisfies LocalApplication
140
+
141
+ const installation = {
142
+ id: 'installation-1',
143
+ applicationId: 'app-remote-1',
144
+ organizationId: 'organization-1',
145
+ installedBy: 'user-1',
146
+ createdAt: '2026-01-01T00:00:00.000Z',
147
+ updatedAt: '2026-01-02T00:00:00.000Z',
148
+ application: {
149
+ title: 'Remote App',
150
+ name: 'remote',
151
+ reference: 'sanity/remote',
152
+ slug: 'remote',
153
+ icon: null,
154
+ // Distinct from the installing org: the bundle is hosted under the publisher's org.
155
+ organizationId: 'organization-publisher',
156
+ },
157
+ interfaces: [
158
+ {
159
+ id: 'remote-view-1',
160
+ type: 'app',
161
+ name: 'remote',
162
+ title: 'Remote App',
163
+ version: '1',
164
+ moduleId: 'App',
165
+ metadata: null,
166
+ },
167
+ {
168
+ id: 'remote-panel-1',
169
+ type: 'panel',
170
+ name: 'remote-panel',
171
+ title: 'Remote Panel',
172
+ version: '1',
173
+ moduleId: 'views/panel',
174
+ metadata: null,
175
+ },
176
+ ],
177
+ } satisfies Installation<InstallationInclude>
117
178
 
118
179
  const emitApplications = (value: unknown[]) =>
119
180
  host.connections.subscribe((client) =>
@@ -140,8 +201,12 @@ describe('useApplications', () => {
140
201
  const {result} = renderHook(() => useApplications())
141
202
 
142
203
  expectTypeOf(result.current).toEqualTypeOf<DashboardApplication[]>()
204
+ // The application member keeps its raw deployment fields; the union also admits installations.
143
205
  expectTypeOf<
144
- Extract<keyof DashboardApplication, 'activeDeployment' | 'config'>
206
+ Extract<
207
+ keyof Exclude<DashboardApplication, {type: 'installation'}>,
208
+ 'activeDeployment' | 'config'
209
+ >
145
210
  >().toEqualTypeOf<'activeDeployment' | 'config'>()
146
211
  const [federated, nonFederated, nonSingleton] = result.current
147
212
  expect(federated).toMatchObject({
@@ -180,6 +245,99 @@ describe('useApplications', () => {
180
245
  ])
181
246
  expect(nonFederated).toMatchObject({views: [], webWorkers: []})
182
247
  expect(nonSingleton?.views[0]?.module.entry).toBe('https://canvas.sanity.studio')
248
+ expect(result.current.map(({isLocal}) => isLocal)).toEqual([false, false, false])
249
+ })
250
+
251
+ it('marks dev-server applications local and loads their modules from the dev server', () => {
252
+ emitApplications([application, localApplication, installation])
253
+
254
+ const {result} = renderHook(() => useApplications())
255
+
256
+ expect(result.current.map(({id, isLocal}) => [id, isLocal])).toEqual([
257
+ ['application-1', false],
258
+ ['application-5', true],
259
+ ['installation-1', false],
260
+ ])
261
+ const local = result.current[1]
262
+ expect(local).toMatchObject({type: 'coreApp', local: {host: 'localhost', port: 3333}})
263
+ expect(local?.views[0]?.module.entry).toBe('http://localhost:3333')
264
+ expect(local?.views[0]?.application).toMatchObject({id: 'application-5', isLocal: true})
265
+ })
266
+
267
+ it('shapes installations alongside applications in one consistent shape', () => {
268
+ emitApplications([application, installation])
269
+
270
+ const {result} = renderHook(() => useApplications())
271
+
272
+ const shaped = result.current.find(({id}) => id === 'installation-1')
273
+ expect(shaped).toMatchObject({
274
+ id: 'installation-1',
275
+ type: 'installation',
276
+ title: 'Remote App',
277
+ name: 'remote',
278
+ reference: 'sanity/remote',
279
+ slug: 'remote',
280
+ icon: null,
281
+ isSingleton: true,
282
+ visibility: 'default',
283
+ externalUrl: null,
284
+ organizationId: 'organization-publisher',
285
+ createdAt: '2026-01-01T00:00:00.000Z',
286
+ updatedAt: '2026-01-02T00:00:00.000Z',
287
+ installation,
288
+ })
289
+ // Raw record fields must not leak onto the shaped entry, nor the raw record onto its views.
290
+ expect(shaped).not.toHaveProperty('applicationId')
291
+ expect(shaped).not.toHaveProperty('installedBy')
292
+ expect(shaped?.views[0]?.application).not.toHaveProperty('installation')
293
+ expect(shaped?.views).toEqual([
294
+ expect.objectContaining({
295
+ name: 'remote',
296
+ surface: 'window',
297
+ module: {
298
+ entry: 'https://remote-apps-organization-publisher.sanity.run',
299
+ moduleId: 'installation-1/App',
300
+ version: '1',
301
+ },
302
+ }),
303
+ expect.objectContaining({
304
+ name: 'remote-panel',
305
+ surface: 'panel',
306
+ module: expect.objectContaining({moduleId: 'installation-1/views/panel'}),
307
+ }),
308
+ ])
309
+ expect(shaped?.webWorkers).toEqual([])
310
+ })
311
+
312
+ it('exposes no views for an installation without interfaces', () => {
313
+ const {interfaces: _interfaces, ...withoutInterfaces} = installation
314
+ emitApplications([withoutInterfaces])
315
+
316
+ const {result} = renderHook(() => useApplications())
317
+
318
+ expect(result.current[0]).toMatchObject({id: 'installation-1', views: [], webWorkers: []})
319
+ })
320
+
321
+ it('exposes no views for an installation without a slug to derive an origin from', () => {
322
+ emitApplications([{...installation, application: {...installation.application, slug: null}}])
323
+
324
+ const {result} = renderHook(() => useApplications())
325
+
326
+ expect(result.current[0]).toMatchObject({id: 'installation-1', views: [], webWorkers: []})
327
+ })
328
+
329
+ it('loads a dev-server application without a module federation manifest', () => {
330
+ const {config: _config, ...withoutManifest} = localApplication
331
+ emitApplications([withoutManifest])
332
+
333
+ const {result} = renderHook(() => useApplications())
334
+
335
+ expect(result.current[0]?.views.map(({name}) => name)).toEqual([
336
+ 'inbox',
337
+ 'notifications',
338
+ 'summary',
339
+ 'library',
340
+ ])
183
341
  })
184
342
 
185
343
  it('follows topic updates without remapping unchanged lists', () => {
@@ -1,62 +1,94 @@
1
- import {type ApplicationBase} from '@sanity/sdk'
1
+ import {type ApplicationBase, type Installation, type InstallationInclude} from '@sanity/sdk'
2
2
  import {getApplicationOrigin} from '@sanity/sdk/_internal'
3
3
  import {type RemoteModuleRef, type ValueOf} from '@sanity/sdk/dashboard'
4
4
  import {useMemo} from 'react'
5
5
 
6
6
  import {useTopic} from './useTopic'
7
7
 
8
- type DashboardTopicApplication = Extract<
8
+ type DashboardTopicEntry = Extract<
9
9
  NonNullable<ValueOf<'applications.list'>>,
10
10
  {ok: true}
11
11
  >['value'][number]
12
+ // The published union distinguishes an `Application` (has `type`) from an `Installation` (has not).
13
+ type DashboardTopicApplication = Extract<DashboardTopicEntry, {type: unknown}>
14
+ type DashboardTopicInstallation = Exclude<DashboardTopicEntry, {type: unknown}>
12
15
  type DashboardApplicationInterface = NonNullable<
13
16
  NonNullable<DashboardTopicApplication['activeDeployment']>['interfaces']
14
17
  >[number]
15
18
  type ViewInterface = Exclude<DashboardApplicationInterface, {type: 'worker'}>
16
19
 
17
20
  /**
18
- * A dashboard view exposed by an application.
21
+ * The shared identity fields attached to each {@link DashboardView.application} and
22
+ * {@link DashboardWebWorker.application}, and the input to module loading. Widens
23
+ * {@link ApplicationBase} so `type` also covers installations.
24
+ * @public
25
+ */
26
+ export type DashboardApplicationBase = Omit<ApplicationBase, 'type'> & {
27
+ readonly type: ApplicationBase['type'] | 'installation'
28
+ /** Served by a CLI dev server rather than a deployment. */
29
+ readonly isLocal: boolean
30
+ }
31
+
32
+ /**
33
+ * A dashboard view exposed by an application or installation.
19
34
  * @public
20
35
  */
21
36
  export type DashboardView = {
22
37
  [Type in ViewInterface['type']]: Omit<Extract<ViewInterface, {type: Type}>, 'type'> & {
23
- readonly application: ApplicationBase
38
+ readonly application: DashboardApplicationBase
24
39
  readonly module: RemoteModuleRef
25
40
  readonly surface: Type extends 'app' ? 'window' : Type
26
41
  }
27
42
  }[ViewInterface['type']]
28
43
 
29
44
  /**
30
- * A web worker exposed by an application.
45
+ * A web worker exposed by an application or installation.
31
46
  * @public
32
47
  */
33
48
  export type DashboardWebWorker = Extract<DashboardApplicationInterface, {type: 'worker'}> & {
34
- readonly application: ApplicationBase
49
+ readonly application: DashboardApplicationBase
35
50
  readonly module: RemoteModuleRef
36
51
  }
37
52
 
38
53
  /**
39
- * The minimal Brett application fields with its loadable views and web workers.
54
+ * An installation shaped into the shared application base, with its raw record kept under
55
+ * `installation` for consumers that need the full record.
40
56
  * @public
41
57
  */
42
- export type DashboardApplication = DashboardTopicApplication & {
43
- readonly views: DashboardView[]
44
- readonly webWorkers: DashboardWebWorker[]
58
+ export type DashboardInstallation = Omit<DashboardApplicationBase, 'type'> & {
59
+ readonly type: 'installation'
60
+ readonly installation: Installation<InstallationInclude>
45
61
  }
46
62
 
63
+ /**
64
+ * A dashboard entry — an application or an installation — in one consistent shape, with its
65
+ * loadable views and web workers. `type` distinguishes the kinds and `isLocal` marks dev-server
66
+ * applications; render a list without branching.
67
+ * @public
68
+ */
69
+ export type DashboardApplication = (DashboardTopicApplication | DashboardInstallation) &
70
+ Pick<DashboardApplicationBase, 'isLocal'> & {
71
+ readonly views: DashboardView[]
72
+ readonly webWorkers: DashboardWebWorker[]
73
+ }
74
+
47
75
  // Only a federated deployment (one with a module federation manifest) exposes loadable modules.
48
- const loadableInterfaces = ({
49
- activeDeployment,
50
- config,
51
- }: DashboardTopicApplication): readonly DashboardApplicationInterface[] =>
52
- config?.mfManifest === undefined ? [] : (activeDeployment?.interfaces ?? [])
76
+ // A dev server is always federated, whether or not the workbench synthesised a manifest for it.
77
+ const loadableInterfaces = (
78
+ {activeDeployment, config}: DashboardTopicApplication,
79
+ isLocal: boolean,
80
+ ): readonly DashboardApplicationInterface[] =>
81
+ isLocal || config?.mfManifest !== undefined ? (activeDeployment?.interfaces ?? []) : []
53
82
 
54
- const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
55
- const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
56
- const interfaces = loadableInterfaces(application)
83
+ // Turns an interface list into views and web workers loaded from `base`'s origin. Shared by
84
+ // applications and installations so the surface mapping lives in one place.
85
+ const loadModules = (
86
+ base: DashboardApplicationBase,
87
+ interfaces: readonly DashboardApplicationInterface[],
88
+ ): {views: DashboardView[]; webWorkers: DashboardWebWorker[]} => {
57
89
  // Nothing to load without interfaces or an origin to load them from.
58
- const entry = interfaces.length === 0 ? null : getApplicationOrigin(applicationBase)
59
- if (entry === null) return {...application, views: [], webWorkers: []}
90
+ const entry = interfaces.length === 0 ? null : getApplicationOrigin(base)
91
+ if (entry === null) return {views: [], webWorkers: []}
60
92
 
61
93
  const views: DashboardView[] = []
62
94
  const webWorkers: DashboardWebWorker[] = []
@@ -64,12 +96,12 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
64
96
  for (const extension of interfaces) {
65
97
  const module: RemoteModuleRef = {
66
98
  entry,
67
- moduleId: `${applicationBase.id}/${extension.moduleId}`,
99
+ moduleId: `${base.id}/${extension.moduleId}`,
68
100
  version: extension.version,
69
101
  }
70
102
 
71
103
  if (extension.type === 'worker') {
72
- webWorkers.push({...extension, application: applicationBase, module})
104
+ webWorkers.push({...extension, application: base, module})
73
105
  continue
74
106
  }
75
107
 
@@ -78,20 +110,66 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
78
110
  // cast is checked by the `DashboardView` mapping above and the surface assertions in the tests.
79
111
  views.push({
80
112
  ...view,
81
- application: applicationBase,
113
+ application: base,
82
114
  module,
83
115
  surface: type === 'app' ? 'window' : type,
84
116
  } as DashboardView)
85
117
  }
86
118
 
87
- return {...application, views, webWorkers}
119
+ return {views, webWorkers}
120
+ }
121
+
122
+ const toApplication = (application: DashboardTopicApplication): DashboardApplication => {
123
+ const {activeDeployment: _activeDeployment, config: _config, ...applicationBase} = application
124
+ const isLocal = 'local' in application
125
+ return {
126
+ ...application,
127
+ isLocal,
128
+ ...loadModules({...applicationBase, isLocal}, loadableInterfaces(application, isLocal)),
129
+ }
130
+ }
131
+
132
+ const toInstallation = (installation: DashboardTopicInstallation): DashboardApplication => {
133
+ const {application} = installation
134
+ // `id` is the installation record's id, not its `applicationId`, so two installs of one app never
135
+ // collide. `organizationId` is the publisher's: the singleton bundle is hosted under the org that
136
+ // published it, and `isSingleton: true` with `externalUrl: null` routes the origin through
137
+ // `getApplicationOrigin`'s singleton branch (`https://<slug>-apps-<publisherOrgId>.sanity.run`).
138
+ // The installing org stays on the raw record. The workbench always serves installations
139
+ // federated, so their interfaces are never gated.
140
+ const base: DashboardApplicationBase = {
141
+ id: installation.id,
142
+ type: 'installation',
143
+ isLocal: false,
144
+ title: application.title,
145
+ name: application.name,
146
+ reference: application.reference,
147
+ icon: application.icon,
148
+ isSingleton: true,
149
+ visibility: 'default',
150
+ slug: application.slug,
151
+ externalUrl: null,
152
+ organizationId: application.organizationId,
153
+ createdAt: installation.createdAt,
154
+ updatedAt: installation.updatedAt,
155
+ }
156
+ // The raw record rides only the entry, not every view's `application`.
157
+ return {
158
+ ...base,
159
+ type: 'installation',
160
+ installation,
161
+ ...loadModules(base, installation.interfaces ?? []),
162
+ }
88
163
  }
89
164
 
90
165
  /**
91
- * Returns the applications available in the dashboard.
166
+ * Returns the applications and installations available in the dashboard, in one consistent shape.
92
167
  *
93
- * Suspends until the dashboard publishes its application list; a cleared list is empty. Throws a
94
- * `TopicError` to the nearest error boundary when the dashboard fails to load applications.
168
+ * Suspends until the dashboard publishes its list; a cleared list is empty. Throws a `TopicError`
169
+ * to the nearest error boundary when the dashboard fails to load. Every entry exposes the same base
170
+ * fields plus `views` and `webWorkers`; `type` distinguishes applications (`'studio' | 'coreApp'`)
171
+ * from installations (`'installation'`), which also carry the raw record under `installation`.
172
+ * `isLocal` marks applications served by a CLI dev server.
95
173
  *
96
174
  * @example
97
175
  * ```tsx
@@ -105,5 +183,11 @@ const toApplication = (application: DashboardTopicApplication): DashboardApplica
105
183
  */
106
184
  export function useApplications(): DashboardApplication[] {
107
185
  const applications = useTopic('applications.list')
108
- return useMemo(() => applications?.map(toApplication) ?? [], [applications])
186
+ return useMemo(
187
+ () =>
188
+ applications?.map((entry) =>
189
+ 'type' in entry ? toApplication(entry) : toInstallation(entry),
190
+ ) ?? [],
191
+ [applications],
192
+ )
109
193
  }
@@ -305,7 +305,22 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
305
305
  })
306
306
 
307
307
  it('maps studio workspaces to resources keyed by projectId:dataset', () => {
308
- emitApplications([studio, coreApp])
308
+ // An installation (no `type`) shares the topic; the hook must tolerate it and map only studios.
309
+ const installation = {id: 'installation-1', applicationId: 'app-remote-1'}
310
+ // A dev-server studio is still a studio, addressed by its dev server.
311
+ const localStudio = {
312
+ ...studio,
313
+ id: 'studio-local',
314
+ slug: null,
315
+ externalUrl: 'http://localhost:3333',
316
+ local: {host: 'localhost', port: 3333},
317
+ activeDeployment: {
318
+ ...deployment,
319
+ applicationId: 'studio-local',
320
+ workspaces: [{...workspace, id: 'workspace-local', projectId: 'project3'}],
321
+ },
322
+ }
323
+ emitApplications([studio, coreApp, installation, localStudio])
309
324
 
310
325
  const {result} = renderHookWithInstance(() => useStudioWorkspacesByProjectIdDataset())
311
326
 
@@ -327,6 +342,13 @@ describe('useStudioWorkspacesByProjectIdDataset (message bus)', () => {
327
342
  expect.objectContaining({id: 'workspace-2', title: 'My Studio', basePath: ''}),
328
343
  ],
329
344
  'project2:dataset2': [expect.objectContaining({id: 'workspace-3'})],
345
+ 'project3:dataset1': [
346
+ expect.objectContaining({
347
+ id: 'workspace-local',
348
+ userApplicationId: 'studio-local',
349
+ url: 'http://localhost:3333',
350
+ }),
351
+ ],
330
352
  })
331
353
  })
332
354
 
@@ -79,7 +79,8 @@ export function useStudioWorkspacesByProjectIdDataset(): StudioWorkspacesResult
79
79
  // The legacy Comlink protocol models studios at the workspace level, so each workspace of a
80
80
  // studio's active deployment becomes one resource, addressed by the studio's origin.
81
81
  function toResources(application: DashboardApplications[number]): DashboardResource[] {
82
- if (application.type !== 'studio') return []
82
+ // Installations join the topic union without a `type`; only studios yield workspace resources.
83
+ if (!('type' in application) || application.type !== 'studio') return []
83
84
  const url = getApplicationOrigin(application) ?? ''
84
85
  return (application.activeDeployment?.workspaces ?? []).map((workspace) => ({
85
86
  id: workspace.id,