@dxos/plugin-space 0.13.0 → 0.14.0

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.
Files changed (48) hide show
  1. package/dist/lib/chunk-ImportSpaceDialog.mjs +2 -2
  2. package/dist/lib/chunk-ImportSpaceDialog.mjs.map +1 -1
  3. package/dist/lib/chunk-MembersContainer.mjs +8 -5
  4. package/dist/lib/chunk-MembersContainer.mjs.map +1 -1
  5. package/dist/lib/chunk-ObjectMasonry.mjs +1 -1
  6. package/dist/lib/chunk-ObjectMasonry.mjs.map +1 -1
  7. package/dist/lib/chunk-RecordArticle.mjs +4 -3
  8. package/dist/lib/chunk-RecordArticle.mjs.map +1 -1
  9. package/dist/lib/chunk-SpaceHomeDashboard.mjs +1 -1
  10. package/dist/lib/chunk-SpaceHomeDashboard.mjs.map +1 -1
  11. package/dist/lib/chunk-SpaceHomeRecent.mjs +41 -19
  12. package/dist/lib/chunk-SpaceHomeRecent.mjs.map +1 -1
  13. package/dist/lib/chunk-SyncStatus.mjs +2 -2
  14. package/dist/lib/chunk-SyncStatus.mjs.map +1 -1
  15. package/dist/lib/chunk-TypeArticle.mjs +1 -1
  16. package/dist/lib/chunk-TypeArticle.mjs.map +1 -1
  17. package/dist/lib/chunk-app-graph-builder.mjs +8 -4
  18. package/dist/lib/chunk-app-graph-builder.mjs.map +1 -1
  19. package/dist/lib/components.mjs +3 -2
  20. package/dist/lib/components.mjs.map +1 -1
  21. package/dist/lib/dashboard.mjs +3 -1
  22. package/dist/lib/dashboard.mjs.map +1 -1
  23. package/dist/types/src/capabilities/app-graph-builder/extensions/database.d.ts.map +1 -1
  24. package/dist/types/src/capabilities/app-graph-builder/extensions/shared.d.ts +2 -0
  25. package/dist/types/src/capabilities/app-graph-builder/extensions/shared.d.ts.map +1 -1
  26. package/dist/types/src/containers/MembersContainer/MembersContainer.d.ts.map +1 -1
  27. package/dist/types/src/containers/RecordArticle/RecordArticle.d.ts.map +1 -1
  28. package/dist/types/src/containers/SpaceHomeArticle/SpaceHomeArticle.stories.d.ts.map +1 -1
  29. package/dist/types/src/containers/SpaceHomeRecent/SpaceHomeRecent.d.ts.map +1 -1
  30. package/dist/types/src/dashboard/stats.d.ts +2 -0
  31. package/dist/types/src/dashboard/stats.d.ts.map +1 -1
  32. package/dist/types/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +62 -62
  34. package/src/capabilities/app-graph-builder/extensions/database.ts +5 -3
  35. package/src/capabilities/app-graph-builder/extensions/shared.test.ts +25 -1
  36. package/src/capabilities/app-graph-builder/extensions/shared.ts +6 -0
  37. package/src/components/CreateObjectPanel/CreateObjectPanel.tsx +2 -2
  38. package/src/containers/ImportSpaceDialog/ImportSpaceDialog.tsx +2 -2
  39. package/src/containers/MembersContainer/MembersContainer.tsx +3 -2
  40. package/src/containers/ObjectMasonry/ObjectMasonryArticle.tsx +1 -1
  41. package/src/containers/RecordArticle/RecordArticle.tsx +2 -5
  42. package/src/containers/SpaceHomeArticle/SpaceHomeArticle.stories.tsx +6 -1
  43. package/src/containers/SpaceHomeDashboard/SpaceHomeDashboard.tsx +1 -1
  44. package/src/containers/SpaceHomeRecent/SpaceHomeRecent.tsx +71 -28
  45. package/src/containers/SyncStatus/SyncStatus.tsx +2 -2
  46. package/src/containers/TypeArticle/TypeArticle.tsx +1 -1
  47. package/src/dashboard/stats.test.ts +12 -2
  48. package/src/dashboard/stats.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/plugin-space",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "DXOS Surface plugin for DXOS ECHO Spaces",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -208,58 +208,58 @@
208
208
  "dependencies": {
209
209
  "@effect/atom-react": "4.0.0",
210
210
  "react-drag-drop-files": "^2.3.8",
211
- "@dxos/app-framework": "0.13.0",
212
- "@dxos/app-toolkit": "0.13.0",
213
- "@dxos/app-graph": "0.13.0",
214
- "@dxos/async": "0.13.0",
215
- "@dxos/cli-util": "0.13.0",
216
- "@dxos/client": "0.13.0",
217
- "@dxos/client-protocol": "0.13.0",
218
- "@dxos/compute": "0.13.0",
219
- "@dxos/display-name": "0.13.0",
220
- "@dxos/echo": "0.13.0",
221
- "@dxos/echo-client": "0.13.0",
222
- "@dxos/echo-react": "0.13.0",
223
- "@dxos/echo-protocol": "0.13.0",
224
- "@dxos/context": "0.13.0",
225
- "@dxos/errors": "0.13.0",
226
- "@dxos/effect": "0.13.0",
227
- "@dxos/extractor": "0.13.0",
228
- "@dxos/graph": "0.13.0",
229
- "@dxos/halo": "0.13.0",
230
- "@dxos/invariant": "0.13.0",
231
- "@dxos/keys": "0.13.0",
232
- "@dxos/migrations": "0.13.0",
233
- "@dxos/plugin-attention": "0.13.0",
234
- "@dxos/halo-react": "0.13.0",
235
- "@dxos/log": "0.13.0",
236
- "@dxos/plugin-client": "0.13.0",
237
- "@dxos/plugin-settings": "0.13.0",
238
- "@dxos/plugin-status-bar": "0.13.0",
239
- "@dxos/plugin-graph": "0.13.0",
240
- "@dxos/progress": "0.13.0",
241
- "@dxos/protocols": "0.13.0",
242
- "@dxos/react-client": "0.13.0",
243
- "@dxos/react-ui": "0.13.0",
244
- "@dxos/react-ui-attention": "0.13.0",
245
- "@dxos/react-ui-components": "0.13.0",
246
- "@dxos/react-ui-dashboard": "0.13.0",
247
- "@dxos/react-ui-dnd": "0.13.0",
248
- "@dxos/react-ui-form": "0.13.0",
249
- "@dxos/react-ui-list": "0.13.0",
250
- "@dxos/react-ui-masonry": "0.13.0",
251
- "@dxos/react-ui-mosaic": "0.13.0",
252
- "@dxos/react-ui-menu": "0.13.0",
253
- "@dxos/react-ui-pickers": "0.13.0",
254
- "@dxos/react-ui-query": "0.13.0",
255
- "@dxos/react-ui-search": "0.13.0",
256
- "@dxos/react-ui-table": "0.13.0",
257
- "@dxos/types": "0.13.0",
258
- "@dxos/schema": "0.13.0",
259
- "@dxos/ui-theme": "0.13.0",
260
- "@dxos/util": "0.13.0",
261
- "@dxos/ui-types": "0.13.0",
262
- "@dxos/shell": "0.13.0"
211
+ "@dxos/app-graph": "0.14.0",
212
+ "@dxos/app-toolkit": "0.14.0",
213
+ "@dxos/app-framework": "0.14.0",
214
+ "@dxos/async": "0.14.0",
215
+ "@dxos/cli-util": "0.14.0",
216
+ "@dxos/client": "0.14.0",
217
+ "@dxos/client-protocol": "0.14.0",
218
+ "@dxos/context": "0.14.0",
219
+ "@dxos/compute": "0.14.0",
220
+ "@dxos/display-name": "0.14.0",
221
+ "@dxos/echo-client": "0.14.0",
222
+ "@dxos/echo": "0.14.0",
223
+ "@dxos/echo-protocol": "0.14.0",
224
+ "@dxos/echo-react": "0.14.0",
225
+ "@dxos/effect": "0.14.0",
226
+ "@dxos/errors": "0.14.0",
227
+ "@dxos/extractor": "0.14.0",
228
+ "@dxos/halo": "0.14.0",
229
+ "@dxos/halo-react": "0.14.0",
230
+ "@dxos/log": "0.14.0",
231
+ "@dxos/keys": "0.14.0",
232
+ "@dxos/graph": "0.14.0",
233
+ "@dxos/migrations": "0.14.0",
234
+ "@dxos/plugin-attention": "0.14.0",
235
+ "@dxos/plugin-client": "0.14.0",
236
+ "@dxos/plugin-graph": "0.14.0",
237
+ "@dxos/plugin-settings": "0.14.0",
238
+ "@dxos/progress": "0.14.0",
239
+ "@dxos/invariant": "0.14.0",
240
+ "@dxos/plugin-status-bar": "0.14.0",
241
+ "@dxos/react-client": "0.14.0",
242
+ "@dxos/protocols": "0.14.0",
243
+ "@dxos/react-ui": "0.14.0",
244
+ "@dxos/react-ui-attention": "0.14.0",
245
+ "@dxos/react-ui-components": "0.14.0",
246
+ "@dxos/react-ui-dashboard": "0.14.0",
247
+ "@dxos/react-ui-dnd": "0.14.0",
248
+ "@dxos/react-ui-form": "0.14.0",
249
+ "@dxos/react-ui-list": "0.14.0",
250
+ "@dxos/react-ui-masonry": "0.14.0",
251
+ "@dxos/react-ui-mosaic": "0.14.0",
252
+ "@dxos/react-ui-pickers": "0.14.0",
253
+ "@dxos/react-ui-query": "0.14.0",
254
+ "@dxos/react-ui-search": "0.14.0",
255
+ "@dxos/react-ui-menu": "0.14.0",
256
+ "@dxos/react-ui-table": "0.14.0",
257
+ "@dxos/types": "0.14.0",
258
+ "@dxos/schema": "0.14.0",
259
+ "@dxos/ui-theme": "0.14.0",
260
+ "@dxos/util": "0.14.0",
261
+ "@dxos/ui-types": "0.14.0",
262
+ "@dxos/shell": "0.14.0"
263
263
  },
264
264
  "devDependencies": {
265
265
  "@effect/atom-react": "4.0.0",
@@ -271,21 +271,21 @@
271
271
  "react": "~19.2.7",
272
272
  "react-dom": "~19.2.7",
273
273
  "vite": "^8.2.2",
274
- "@dxos/extractor-lib": "0.13.0",
275
- "@dxos/agent-runtime": "0.13.0",
276
- "@dxos/plugin-testing": "0.13.0",
277
- "@dxos/react-ui": "0.13.0",
278
- "@dxos/plugin-preview": "0.13.0",
279
- "@dxos/ui-theme": "0.13.0",
280
- "@dxos/random": "0.13.0"
274
+ "@dxos/agent-runtime": "0.14.0",
275
+ "@dxos/extractor-lib": "0.14.0",
276
+ "@dxos/plugin-preview": "0.14.0",
277
+ "@dxos/plugin-testing": "0.14.0",
278
+ "@dxos/random": "0.14.0",
279
+ "@dxos/react-ui": "0.14.0",
280
+ "@dxos/ui-theme": "0.14.0"
281
281
  },
282
282
  "peerDependencies": {
283
283
  "@effect/atom-react": "4.0.0",
284
284
  "effect": "4.0.0",
285
285
  "react": "~19.2.7",
286
286
  "react-dom": "~19.2.7",
287
- "@dxos/react-ui": "^0.13.0",
288
- "@dxos/ui-theme": "^0.13.0"
287
+ "@dxos/react-ui": "^0.14.0",
288
+ "@dxos/ui-theme": "^0.14.0"
289
289
  },
290
290
  "publishConfig": {
291
291
  "access": "public"
@@ -28,6 +28,7 @@ import { ViewAnnotation } from '@dxos/schema';
28
28
  import { isLabel, toLocalizedString } from '@dxos/ui-types/translations';
29
29
  import { createFilename, downloadBlob, isNonNullable } from '@dxos/util';
30
30
 
31
+ import { SPACE_STATS_QUERY, typeUrisWithObjects } from '#dashboard';
31
32
  import { meta } from '#meta';
32
33
  import { SpaceCapabilities, SpaceEvents, SpaceOperation } from '#types';
33
34
 
@@ -121,17 +122,18 @@ export const createDatabaseExtensions = Effect.fnUntraced(function* () {
121
122
  });
122
123
 
123
124
  const viewIndex = buildViewIndex(get, space, allSchemas);
125
+ // Index-only per-type counts; the same query as the space dashboard, so both share one result.
126
+ const withObjects = typeUrisWithObjects(get(space.db.query(SPACE_STATS_QUERY).atom));
124
127
 
125
128
  const visibleSchemas = userSchemas.filter((schema) => {
126
129
  if (Type.getDatabase(schema) != null) {
127
130
  return true;
128
131
  }
129
132
  const typeUri = Type.getURI(schema);
130
- const objects = get(space.db.query(Filter.type(typeUri)).atom);
131
133
  if (ViewAnnotation.has(schema)) {
132
- return objects.some((obj) => !viewIndex.isView(obj));
134
+ return viewIndex.typeUrisWithNonViewObjects.has(typeUri);
133
135
  }
134
- return objects.length > 0 || viewIndex.typeUrisWithViews.has(typeUri);
136
+ return withObjects.has(typeUri) || viewIndex.typeUrisWithViews.has(typeUri);
135
137
  });
136
138
 
137
139
  // Sort alphabetically by display name. Static types' labels are `typename.label` translation
@@ -28,6 +28,12 @@ const TestViewWrapper = Type.makeObject(DXN.make('com.example.type.viewWrapper',
28
28
  }).pipe(ViewAnnotation.set(['view'])),
29
29
  );
30
30
 
31
+ const TestOptionalViewWrapper = Type.makeObject(DXN.make('com.example.type.optionalViewWrapper', '0.1.0'))(
32
+ Schema.Struct({
33
+ view: Schema.optional(Ref.Ref(View.View)),
34
+ }).pipe(ViewAnnotation.set(['view'])),
35
+ );
36
+
31
37
  describe('buildViewIndex', () => {
32
38
  let testBuilder: EchoTestBuilder;
33
39
  let db: EchoDatabase;
@@ -35,7 +41,9 @@ describe('buildViewIndex', () => {
35
41
 
36
42
  beforeEach(async () => {
37
43
  testBuilder = await new EchoTestBuilder().open();
38
- const result = await testBuilder.createDatabase({ types: [TestContact, TestViewWrapper, View.View] });
44
+ const result = await testBuilder.createDatabase({
45
+ types: [TestContact, TestViewWrapper, TestOptionalViewWrapper, View.View],
46
+ });
39
47
  db = result.db;
40
48
  registry = AtomRegistry.make();
41
49
  });
@@ -101,6 +109,22 @@ describe('buildViewIndex', () => {
101
109
  expect(views[0].id).toBe(tableView.id);
102
110
  });
103
111
 
112
+ test('typeUrisWithNonViewObjects lists view types with an instance that is not a view', async ({ expect }) => {
113
+ const viewObj = db.add(View.make({ query: { ast: Query.select(Filter.type(TestContact)).ast } }));
114
+ db.add(Obj.make(TestViewWrapper, { view: Ref.make(viewObj) }));
115
+ db.add(Obj.make(TestOptionalViewWrapper, {}));
116
+ await db.flush();
117
+
118
+ const allSchemas = db.graph.registry
119
+ .list()
120
+ .filter(Type.isType)
121
+ .filter((t) => !Type.isTypeKind(t));
122
+ const viewIndex = buildViewIndex(registry.get.bind(registry) as any, { db } as any, allSchemas);
123
+
124
+ expect(viewIndex.typeUrisWithNonViewObjects.has(Type.getURI(TestOptionalViewWrapper))).toBe(true);
125
+ expect(viewIndex.typeUrisWithNonViewObjects.has(Type.getURI(TestViewWrapper))).toBe(false);
126
+ });
127
+
104
128
  test('returns empty index when no view schemas exist', async ({ expect }) => {
105
129
  await db.flush();
106
130
 
@@ -104,6 +104,8 @@ export type ViewIndex = {
104
104
  getViewsForTypeUri: (typeUri: string) => Obj.Any[];
105
105
  /** True when the schema has `ViewAnnotation` and that path resolves non-null on `object`; false otherwise (no annotation, or null/undefined path — e.g. Kanban with unset `view`). */
106
106
  isView: (object: Obj.Any) => boolean;
107
+ /** View-annotated type URIs with at least one instance that is not a view. */
108
+ typeUrisWithNonViewObjects: Set<string>;
107
109
  };
108
110
 
109
111
  /**
@@ -120,6 +122,7 @@ export const buildViewIndex = (get: Atom.AtomContext, space: Space, schemas: Typ
120
122
  // Used by `isView` to distinguish view instances from regular instances of
121
123
  // the same schema (e.g. items-variant Kanban vs view-variant Kanban).
122
124
  const viewObjectIds = new Set<string>();
125
+ const typeUrisWithNonViewObjects = new Set<string>();
123
126
 
124
127
  if (viewSchemas.length > 0) {
125
128
  const filter = Filter.or(...viewSchemas.map((schema) => Filter.type(schema)));
@@ -149,6 +152,8 @@ export const buildViewIndex = (get: Atom.AtomContext, space: Space, schemas: Typ
149
152
  // be of a view-annotated schema.
150
153
  if (holder != null) {
151
154
  viewObjectIds.add(viewObject.id);
155
+ } else if (holderType) {
156
+ typeUrisWithNonViewObjects.add(Type.getURI(holderType));
152
157
  }
153
158
 
154
159
  const viewTarget = holder != null ? get(Obj.atom(holder as Obj.Any)) : undefined;
@@ -165,5 +170,6 @@ export const buildViewIndex = (get: Atom.AtomContext, space: Space, schemas: Typ
165
170
  typeUrisWithViews: new Set(viewsByTypeUri.keys()),
166
171
  getViewsForTypeUri: (typeUri) => viewsByTypeUri.get(typeUri) ?? [],
167
172
  isView: (object) => viewObjectIds.has(object.id),
173
+ typeUrisWithNonViewObjects,
168
174
  };
169
175
  };
@@ -274,14 +274,14 @@ const SelectType = ({ options, onChange }: SelectTypeProps) => {
274
274
  size='xl'
275
275
  classNames={getIconHueStyles(option.iconHue)}
276
276
  />
277
- <div className='flex flex-col min-w-0 grow gap-0.5'>
277
+ <Layout.Flex column classNames='min-w-0 grow gap-0.5'>
278
278
  <span className='truncate'>{option.label}</span>
279
279
  {(option.plugin || option.description) && (
280
280
  <span className='truncate text-fg-muted text-xs'>
281
281
  {option.plugin ? t('plugin-subtitle.label', { plugin: option.plugin }) : option.description}
282
282
  </span>
283
283
  )}
284
- </div>
284
+ </Layout.Flex>
285
285
  </Picker.Item>
286
286
  ))}
287
287
  </SearchList.Viewport>
@@ -78,7 +78,7 @@ export const ImportSpaceDialog = () => {
78
78
  gap='sm'
79
79
  asChild
80
80
  role='status'
81
- classNames='my-4 p-8 border-2 border-dashed border-neutral-500/50 rounded-sm'
81
+ classNames='my-4 p-8 border-2 border-dashed border-separator rounded-sm'
82
82
  >
83
83
  <div>
84
84
  <Icon.Icon icon='ph--spinner-gap--regular' size='xl' spin />
@@ -88,7 +88,7 @@ export const ImportSpaceDialog = () => {
88
88
  ) : (
89
89
  <FileUploader
90
90
  types={['json', 'tar']}
91
- classes='block my-4 p-8 border-2 border-dashed border-neutral-500/50 rounded-sm flex items-center justify-center gap-2 cursor-pointer'
91
+ classes='block my-4 p-8 border-2 border-dashed border-separator rounded-sm flex items-center justify-center gap-2 cursor-pointer'
92
92
  dropMessageStyle={{ border: 'none', backgroundColor: '#EEE' }}
93
93
  handleChange={(file: File) => {
94
94
  void handleFile(file);
@@ -27,6 +27,7 @@ import { Form } from '@dxos/react-ui-form';
27
27
  import * as Button from '@dxos/react-ui/Button';
28
28
  import * as UiHooks from '@dxos/react-ui/Hooks';
29
29
  import * as Icon from '@dxos/react-ui/Icon';
30
+ import * as Layout from '@dxos/react-ui/Layout';
30
31
  import * as QrCode from '@dxos/react-ui/QrCode';
31
32
  import * as SystemButton from '@dxos/react-ui/SystemButton';
32
33
  import {
@@ -296,7 +297,7 @@ const InvitationQR = ({ id, url, onCancel }: { id: string; url: string; onCancel
296
297
  return (
297
298
  <>
298
299
  <p className='text-fg-muted'>{t('qr-code.description', { ns: meta.profile.key })}</p>
299
- <div role='group' className='grid grid-cols-[1fr_min-content] my-2 gap-2'>
300
+ <Layout.Grid role='group' cols={['fill', 'min']} gap='sm' classNames='my-2'>
300
301
  <div className='w-full aspect-square relative text-fg-muted'>
301
302
  <QrCode.QrCode aria-labelledby={qrLabel} errorCorrection='Q' value={url ?? 'never'} />
302
303
  <Centered>
@@ -307,7 +308,7 @@ const InvitationQR = ({ id, url, onCancel }: { id: string; url: string; onCancel
307
308
  {t('qr.label')}
308
309
  </span>
309
310
  <SystemButton.Clipboard value={url ?? 'never'} />
310
- </div>
311
+ </Layout.Grid>
311
312
  <Button.Root variant='ghost' onClick={onCancel}>
312
313
  {t('cancel.label')}
313
314
  </Button.Root>
@@ -110,7 +110,7 @@ export const ObjectMasonryArticle = ({ role, attendableId, objects, emptyMessage
110
110
  <ObjectMasonry cacheKey={attendableId} items={items} />
111
111
  )}
112
112
  </Panel.Body>
113
- <Panel.Footer classNames='flex items-center p-1 border-t border-separator-subtle'>
113
+ <Panel.Footer classNames='items-center p-1 border-t border-separator-subtle'>
114
114
  {t('item-count.label', { count: items.length })}
115
115
  </Panel.Footer>
116
116
  </Panel.Root>
@@ -27,7 +27,6 @@ import * as Hooks from '@dxos/react-ui/Hooks';
27
27
  import * as Layout from '@dxos/react-ui/Layout';
28
28
  import * as Panel from '@dxos/react-ui/Panel';
29
29
  import * as ScrollArea from '@dxos/react-ui/ScrollArea';
30
- import { mx } from '@dxos/ui-theme';
31
30
 
32
31
  import { ObjectCard, RelatedTypeFilter } from '#components';
33
32
  import { useRelatedObjects, useRelatedTypeFilter } from '#hooks';
@@ -83,9 +82,7 @@ export const RecordArticle = ({ role, subject, attendableId }: AppSurface.Object
83
82
 
84
83
  {/* Gated on the unfiltered set so hiding every type does not remove the filter itself. */}
85
84
  {relatedObjects.length > 0 && (
86
- <div
87
- className={mx('dx-expand flex flex-col gap-form-gap', singleColumn ? 'dx-card-max-width' : 'w-full')}
88
- >
85
+ <Layout.Flex column gap='form' classNames={['dx-expand', singleColumn ? 'dx-card-max-width' : 'w-full']}>
89
86
  <Field.Root>
90
87
  <Field.Label>{t('related-objects.label')}</Field.Label>
91
88
  </Field.Root>
@@ -101,7 +98,7 @@ export const RecordArticle = ({ role, subject, attendableId }: AppSurface.Object
101
98
  <Masonry.Viewport items={related} />
102
99
  </Masonry.Content>
103
100
  </Masonry.Root>
104
- </div>
101
+ </Layout.Flex>
105
102
  )}
106
103
  </ScrollArea.Viewport>
107
104
  </ScrollArea.Root>
@@ -32,13 +32,18 @@ class Task extends Type.makeObject<Task>(DXN.make('org.dxos.type.test.task', '0.
32
32
  }).pipe(
33
33
  Annotation.LabelAnnotation.set(['name']),
34
34
  Annotation.IconAnnotation.set({ icon: 'ph--check-square--regular' }),
35
+ Annotation.UserType.set(),
35
36
  ),
36
37
  ) {}
37
38
 
38
39
  class Note extends Type.makeObject<Note>(DXN.make('org.dxos.type.test.note', '0.1.0'))(
39
40
  Schema.Struct({
40
41
  content: Schema.optional(Schema.String),
41
- }).pipe(Annotation.LabelAnnotation.set(['content']), Annotation.IconAnnotation.set({ icon: 'ph--note--regular' })),
42
+ }).pipe(
43
+ Annotation.LabelAnnotation.set(['content']),
44
+ Annotation.IconAnnotation.set({ icon: 'ph--note--regular' }),
45
+ Annotation.UserType.set(),
46
+ ),
42
47
  ) {}
43
48
 
44
49
  const OBJECT_COUNT = 24;
@@ -53,7 +53,7 @@ export const SpaceHomeDashboard = ({ space, stats = STAT_IDS, onClose }: SpaceHo
53
53
  'plugins': plugins,
54
54
  };
55
55
 
56
- if (!space) {
56
+ if (!space || counts.length === 0) {
57
57
  return null;
58
58
  }
59
59
 
@@ -2,6 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ import * as Array from 'effect/Array';
5
6
  import React, { useCallback, useMemo } from 'react';
6
7
 
7
8
  import * as Hooks from '@dxos/app-framework/Hooks';
@@ -10,7 +11,7 @@ import * as GraphPath from '@dxos/app-toolkit/GraphPath';
10
11
  import * as HomeSection from '@dxos/app-toolkit/HomeSection';
11
12
  import * as LayoutOperation from '@dxos/app-toolkit/LayoutOperation';
12
13
  import * as TypeOptions from '@dxos/app-toolkit/TypeOptions';
13
- import { Collection, Filter, Obj, Order, Query, Type } from '@dxos/echo';
14
+ import { Aggregate, Collection, Filter, Obj, Order, Query, Type } from '@dxos/echo';
14
15
  import { useQuery } from '@dxos/echo-react';
15
16
  import { type Space } from '@dxos/react-client/echo';
16
17
  import { Masonry } from '@dxos/react-ui-masonry';
@@ -18,6 +19,7 @@ import * as Card from '@dxos/react-ui/Card';
18
19
  import * as UiHooks from '@dxos/react-ui/Hooks';
19
20
  import * as Icon from '@dxos/react-ui/Icon';
20
21
  import * as Layout from '@dxos/react-ui/Layout';
22
+ import * as Status from '@dxos/react-ui/Status';
21
23
  import * as Theme from '@dxos/react-ui/Theme';
22
24
  import { getStyles } from '@dxos/ui-theme';
23
25
 
@@ -31,51 +33,92 @@ type SpaceScopedProps = {
31
33
  onClose?: () => void;
32
34
  };
33
35
 
34
- /**
35
- * Recent-objects region for the Home article. Queries the most-recently-modified objects of
36
- * registered, non-hidden, non-relation, non-collection types and renders them as a Masonry of
37
- * tiles. Renders nothing (no heading) when the space has no recent objects — the starter-prompt
38
- * contributor (plugin-assistant) fills the empty state instead.
39
- */
40
36
  export const SpaceHomeRecent = ({ space, onClose }: SpaceScopedProps) => {
41
37
  const { t } = UiHooks.useTranslation(meta.profile.key);
42
38
 
43
- const schemas = Hooks.useCapabilities(AppCapabilities.Schema);
44
- const filter = useMemo(() => {
45
- const collectionTypename = Type.getTypename(Collection.Collection);
46
- const types = schemas
47
- .flat()
48
- .filter(Type.isType)
49
- .filter((type) => TypeOptions.isUserType(type))
50
- .filter((type) => Type.getTypename(type) !== collectionTypename);
51
- return types.length > 0 ? Filter.or(...types.map((type) => Filter.type(type))) : undefined;
52
- }, [schemas]);
53
-
54
- const query = useMemo(
39
+ const { recent, placeholderCount } = useRecentObjects(space);
40
+ const items = useMemo<RecentItem[]>(
55
41
  () =>
56
- Query.select(filter ?? Filter.everything())
57
- .orderBy(Order.updated('desc'))
58
- .limit(RECENT_LIMIT),
59
- [filter],
42
+ placeholderCount > 0
43
+ ? Array.makeBy(placeholderCount, (index) => new Placeholder(`placeholder-${index}`))
44
+ : recent,
45
+ [placeholderCount, recent],
60
46
  );
61
-
62
- const recent = useQuery(filter && space ? space.db : undefined, query);
63
- if (recent.length === 0) {
47
+ if (recent.length === 0 && placeholderCount === 0) {
64
48
  return null;
65
49
  }
66
50
 
67
51
  return (
68
52
  <HomeSection.Root>
69
53
  <HomeSection.Header title={t('space-home.recent.heading')} onClose={onClose} />
70
- <Masonry.Root Tile={RecentObjectTile}>
54
+ <Masonry.Root Tile={RecentTile}>
71
55
  <Masonry.Content padding={false} scrollbars={false}>
72
- <Masonry.Viewport items={recent} getId={(object) => object.id} />
56
+ <Masonry.Viewport items={items} getId={(item) => item.id} cacheKey={space && `${space.id}/recent`} />
73
57
  </Masonry.Content>
74
58
  </Masonry.Root>
75
59
  </HomeSection.Root>
76
60
  );
77
61
  };
78
62
 
63
+ const useRecentObjects = (space?: Space): { recent: Obj.Unknown[]; placeholderCount: number } => {
64
+ const schemas = Hooks.useCapabilities(AppCapabilities.Schema);
65
+ const filter = useMemo(() => recentObjectsFilter(schemas.flat()), [schemas]);
66
+ const query = useMemo(
67
+ () =>
68
+ Query.select(filter ?? Filter.everything())
69
+ .orderBy(Order.updated('desc'))
70
+ .limit(RECENT_LIMIT),
71
+ [filter],
72
+ );
73
+ const countQuery = useMemo(
74
+ () => Query.select(filter ?? Filter.everything()).aggregate({ count: Aggregate.count() }),
75
+ [filter],
76
+ );
77
+
78
+ const db = filter && space ? space.db : undefined;
79
+ const recent = useQuery(db, query);
80
+ const [total] = useQuery(db, countQuery);
81
+ return { recent, placeholderCount: recent.length === 0 ? Math.min(total?.count ?? 0, RECENT_LIMIT) : 0 };
82
+ };
83
+
84
+ const recentObjectsFilter = (schemas: readonly unknown[]) => {
85
+ const collectionTypename = Type.getTypename(Collection.Collection);
86
+ const types = Array.dedupeWith(
87
+ schemas
88
+ .filter(Type.isType)
89
+ .filter((type) => TypeOptions.isUserType(type))
90
+ .filter((type) => Type.getTypename(type) !== collectionTypename),
91
+ (a, b) => Type.getURI(a) === Type.getURI(b),
92
+ );
93
+ return types.length > 0 ? Filter.or(...types.map((type) => Filter.type(type))) : undefined;
94
+ };
95
+
96
+ class Placeholder {
97
+ constructor(readonly id: string) {}
98
+ }
99
+
100
+ type RecentItem = Obj.Unknown | Placeholder;
101
+
102
+ const RecentTile = ({ data, index }: { data: RecentItem; index: number }) =>
103
+ data instanceof Placeholder ? <PlaceholderTile /> : <RecentObjectTile data={data} index={index} />;
104
+
105
+ RecentTile.displayName = 'RecentTile';
106
+
107
+ const PlaceholderTile = () => (
108
+ <Card.Root>
109
+ <Card.Header>
110
+ <Layout.Block>
111
+ <Status.Skeleton variant='circle' />
112
+ </Layout.Block>
113
+ <Card.Title>
114
+ <Status.Skeleton variant='text' />
115
+ </Card.Title>
116
+ </Card.Header>
117
+ </Card.Root>
118
+ );
119
+
120
+ PlaceholderTile.displayName = 'PlaceholderTile';
121
+
79
122
  const RecentObjectTile = ({ data }: { data: Obj.Unknown; index: number }) => {
80
123
  const { invokePromise } = Hooks.useOperationInvoker();
81
124
  const { t } = UiHooks.useTranslation(meta.profile.key);
@@ -112,7 +112,7 @@ const EdgeConnectionPopover = ({ status }: { status: EdgeStatus }) => {
112
112
 
113
113
  {/* Upload Speed */}
114
114
  <Layout.Grid cols='subgrid' gap='sm' align='center' classNames='text-sm'>
115
- <Icon.Icon icon='ph--arrow-up--regular' classNames='text-green-500' />
115
+ <Icon.Icon icon='ph--arrow-up--regular' classNames='text-green-text' />
116
116
  <span className='text-fg-muted'>{t('sync-upload.label')}</span>
117
117
  <UnitValue value={status.messagesSent} format={Unit.Thousand} />
118
118
  <UnitValue value={status.rateBytesUp} format={Unit.Kilobyte} suffix='/s' />
@@ -120,7 +120,7 @@ const EdgeConnectionPopover = ({ status }: { status: EdgeStatus }) => {
120
120
 
121
121
  {/* Download Speed */}
122
122
  <Layout.Grid cols='subgrid' gap='sm' align='center' classNames='text-sm'>
123
- <Icon.Icon icon='ph--arrow-down--regular' classNames='text-orange-500' />
123
+ <Icon.Icon icon='ph--arrow-down--regular' classNames='text-orange-text' />
124
124
  <span className='text-fg-muted'>{t('sync-download.label')}</span>
125
125
  <UnitValue value={status.messagesReceived} format={Unit.Thousand} />
126
126
  <UnitValue value={status.rateBytesDown} format={Unit.Kilobyte} suffix='/s' />
@@ -316,7 +316,7 @@ export const TypeArticle = ({ role, space, type, attendableId }: TypeArticleProp
316
316
  </LayoutPanel>
317
317
  )}
318
318
  </Panel.Body>
319
- <Panel.Footer classNames='flex items-center p-1 border-t border-separator-subtle'>
319
+ <Panel.Footer classNames='items-center p-1 border-t border-separator-subtle'>
320
320
  {t('item-count.label', { count: tileItems.length })}
321
321
  </Panel.Footer>
322
322
  </Panel.Root>
@@ -4,11 +4,11 @@
4
4
 
5
5
  import { afterEach, beforeEach, describe, test } from 'vitest';
6
6
 
7
- import { Feed, Obj } from '@dxos/echo';
7
+ import { Feed, Obj, Type } from '@dxos/echo';
8
8
  import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
9
  import { TestSchema } from '@dxos/echo/testing';
10
10
 
11
- import { SPACE_STATS_QUERY, toSpaceStats } from './stats.ts';
11
+ import { SPACE_STATS_QUERY, toSpaceStats, typeUrisWithObjects } from './stats.ts';
12
12
 
13
13
  describe('toSpaceStats', () => {
14
14
  let builder: EchoTestBuilder;
@@ -32,6 +32,16 @@ describe('toSpaceStats', () => {
32
32
  expect(toSpaceStats(rows, 7)).toEqual({ objects: 3, feeds: 2, types: 2, plugins: 7 });
33
33
  });
34
34
 
35
+ test('typeUrisWithObjects names each type with objects by its Type.getURI', async ({ expect }) => {
36
+ const { db } = await builder.createDatabase({ types: [Feed.Feed, TestSchema.Expando] });
37
+ db.add(Obj.make(TestSchema.Expando, { value: 1 }));
38
+ await db.flush({ indexes: true });
39
+
40
+ const uris = typeUrisWithObjects(await db.query(SPACE_STATS_QUERY).run());
41
+ expect(uris.has(Type.getURI(TestSchema.Expando))).toBe(true);
42
+ expect(uris.has(Type.getURI(Feed.Feed))).toBe(false);
43
+ });
44
+
35
45
  test('two schema versions of one type count as one type', ({ expect }) => {
36
46
  const rows = [
37
47
  { type: 'dxn:example.com.type.task:0.1.0', count: 2 },
@@ -24,6 +24,10 @@ export const SPACE_STATS_QUERY = Query.select(Filter.everything()).aggregate({
24
24
  count: Aggregate.count(),
25
25
  });
26
26
 
27
+ /** Type URIs with at least one live object, in the form `Type.getURI` returns. */
28
+ export const typeUrisWithObjects = (rows: readonly TypeCount[]): Set<string> =>
29
+ new Set(rows.flatMap((row) => (row.type !== null && row.count > 0 ? [row.type] : [])));
30
+
27
31
  /** The versionless typename a stored type URI names, or the URI itself when it is not a DXN. */
28
32
  export const typenameOf = (type: string): string => {
29
33
  const dxn = DXN.tryMake(type);