@dxos/plugin-simple-layout 0.8.4-main.c85a9c8dae → 0.8.4-main.d05673bc65

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 (88) hide show
  1. package/dist/lib/browser/{chunk-TMZNLVT2.mjs → chunk-MDPEKLKR.mjs} +55 -62
  2. package/dist/lib/browser/chunk-MDPEKLKR.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-7VLT3S46.mjs → chunk-MRR7PXSM.mjs} +3 -3
  4. package/dist/lib/browser/{chunk-7VLT3S46.mjs.map → chunk-MRR7PXSM.mjs.map} +1 -1
  5. package/dist/lib/browser/index.mjs +6 -6
  6. package/dist/lib/browser/meta.json +1 -1
  7. package/dist/lib/browser/{operation-resolver-BYRIQOQT.mjs → operation-resolver-VTZ6HZ4B.mjs} +24 -35
  8. package/dist/lib/browser/operation-resolver-VTZ6HZ4B.mjs.map +7 -0
  9. package/dist/lib/browser/{react-root-MMB575WY.mjs → react-root-WVQYY2JA.mjs} +3 -3
  10. package/dist/lib/browser/{react-surface-M6CURANW.mjs → react-surface-VLBR37ED.mjs} +11 -8
  11. package/dist/lib/browser/{react-surface-M6CURANW.mjs.map → react-surface-VLBR37ED.mjs.map} +3 -3
  12. package/dist/lib/browser/{state-A3PGDWWZ.mjs → state-TXSMUWYI.mjs} +2 -2
  13. package/dist/lib/browser/{url-handler-HTIUY6WL.mjs → url-handler-RBRONH7S.mjs} +18 -19
  14. package/dist/lib/browser/url-handler-RBRONH7S.mjs.map +7 -0
  15. package/dist/lib/node-esm/{chunk-FLOYBAHE.mjs → chunk-DCKASLMP.mjs} +55 -62
  16. package/dist/lib/node-esm/chunk-DCKASLMP.mjs.map +7 -0
  17. package/dist/lib/node-esm/{chunk-VIDE5UMB.mjs → chunk-WMNTJ2MK.mjs} +3 -3
  18. package/dist/lib/node-esm/{chunk-VIDE5UMB.mjs.map → chunk-WMNTJ2MK.mjs.map} +1 -1
  19. package/dist/lib/node-esm/index.mjs +6 -6
  20. package/dist/lib/node-esm/meta.json +1 -1
  21. package/dist/lib/node-esm/{operation-resolver-BDTFNCS2.mjs → operation-resolver-R7CQ6ERU.mjs} +24 -35
  22. package/dist/lib/node-esm/operation-resolver-R7CQ6ERU.mjs.map +7 -0
  23. package/dist/lib/node-esm/{react-root-ENZKVSY4.mjs → react-root-XBNDM7BE.mjs} +3 -3
  24. package/dist/lib/node-esm/{react-surface-ITVNQYLG.mjs → react-surface-U5NHA367.mjs} +11 -8
  25. package/dist/lib/node-esm/{react-surface-ITVNQYLG.mjs.map → react-surface-U5NHA367.mjs.map} +3 -3
  26. package/dist/lib/node-esm/{state-ZCFZTTPL.mjs → state-JMX6FAG4.mjs} +2 -2
  27. package/dist/lib/node-esm/{url-handler-WBVVKVPC.mjs → url-handler-QSMCH3JB.mjs} +18 -19
  28. package/dist/lib/node-esm/url-handler-QSMCH3JB.mjs.map +7 -0
  29. package/dist/types/src/capabilities/operation-resolver/operation-resolver.d.ts.map +1 -1
  30. package/dist/types/src/capabilities/react-surface/react-surface.d.ts.map +1 -1
  31. package/dist/types/src/capabilities/url-handler/url-handler.d.ts +2 -2
  32. package/dist/types/src/capabilities/url-handler/url-handler.d.ts.map +1 -1
  33. package/dist/types/src/components/ContentLoading/ContentLoading.d.ts.map +1 -0
  34. package/dist/types/src/components/ContentLoading/ContentLoading.stories.d.ts.map +1 -0
  35. package/dist/types/src/components/ContentLoading/index.d.ts +2 -0
  36. package/dist/types/src/components/ContentLoading/index.d.ts.map +1 -0
  37. package/dist/types/src/components/NavBranch/NavBranch.d.ts +11 -0
  38. package/dist/types/src/components/NavBranch/NavBranch.d.ts.map +1 -0
  39. package/dist/types/src/components/NavBranch/index.d.ts +2 -0
  40. package/dist/types/src/components/NavBranch/index.d.ts.map +1 -0
  41. package/dist/types/src/components/SimpleLayout/Main.d.ts.map +1 -1
  42. package/dist/types/src/components/hooks.d.ts +4 -2
  43. package/dist/types/src/components/hooks.d.ts.map +1 -1
  44. package/dist/types/src/components/index.d.ts +1 -1
  45. package/dist/types/src/hooks/actions.d.ts +3 -4
  46. package/dist/types/src/hooks/actions.d.ts.map +1 -1
  47. package/dist/types/src/hooks/useAppBarProps.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useDrawerActions.d.ts.map +1 -1
  49. package/dist/types/src/hooks/useNavbarActions.d.ts.map +1 -1
  50. package/dist/types/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +27 -27
  52. package/src/capabilities/operation-resolver/operation-resolver.ts +19 -34
  53. package/src/capabilities/react-surface/react-surface.tsx +8 -6
  54. package/src/capabilities/url-handler/url-handler.ts +11 -35
  55. package/src/components/ContentLoading/index.ts +5 -0
  56. package/src/components/Home/Home.tsx +3 -3
  57. package/src/components/{Workspace/Workspace.tsx → NavBranch/NavBranch.tsx} +18 -13
  58. package/src/components/{Workspace → NavBranch}/index.ts +1 -1
  59. package/src/components/SimpleLayout/Drawer.tsx +3 -12
  60. package/src/components/SimpleLayout/Main.tsx +3 -4
  61. package/src/components/hooks.ts +8 -8
  62. package/src/components/index.ts +1 -1
  63. package/src/hooks/actions.ts +13 -15
  64. package/src/hooks/useAppBarProps.ts +1 -2
  65. package/src/hooks/useDrawerActions.ts +7 -5
  66. package/src/hooks/useNavbarActions.ts +5 -4
  67. package/src/meta.ts +1 -1
  68. package/src/types/capabilities.ts +1 -1
  69. package/dist/lib/browser/chunk-TMZNLVT2.mjs.map +0 -7
  70. package/dist/lib/browser/operation-resolver-BYRIQOQT.mjs.map +0 -7
  71. package/dist/lib/browser/url-handler-HTIUY6WL.mjs.map +0 -7
  72. package/dist/lib/node-esm/chunk-FLOYBAHE.mjs.map +0 -7
  73. package/dist/lib/node-esm/operation-resolver-BDTFNCS2.mjs.map +0 -7
  74. package/dist/lib/node-esm/url-handler-WBVVKVPC.mjs.map +0 -7
  75. package/dist/types/src/components/ContentLoading.d.ts.map +0 -1
  76. package/dist/types/src/components/ContentLoading.stories.d.ts.map +0 -1
  77. package/dist/types/src/components/Workspace/Workspace.d.ts +0 -11
  78. package/dist/types/src/components/Workspace/Workspace.d.ts.map +0 -1
  79. package/dist/types/src/components/Workspace/index.d.ts +0 -2
  80. package/dist/types/src/components/Workspace/index.d.ts.map +0 -1
  81. /package/dist/lib/browser/{react-root-MMB575WY.mjs.map → react-root-WVQYY2JA.mjs.map} +0 -0
  82. /package/dist/lib/browser/{state-A3PGDWWZ.mjs.map → state-TXSMUWYI.mjs.map} +0 -0
  83. /package/dist/lib/node-esm/{react-root-ENZKVSY4.mjs.map → react-root-XBNDM7BE.mjs.map} +0 -0
  84. /package/dist/lib/node-esm/{state-ZCFZTTPL.mjs.map → state-JMX6FAG4.mjs.map} +0 -0
  85. /package/dist/types/src/components/{ContentLoading.d.ts → ContentLoading/ContentLoading.d.ts} +0 -0
  86. /package/dist/types/src/components/{ContentLoading.stories.d.ts → ContentLoading/ContentLoading.stories.d.ts} +0 -0
  87. /package/src/components/{ContentLoading.stories.tsx → ContentLoading/ContentLoading.stories.tsx} +0 -0
  88. /package/src/components/{ContentLoading.tsx → ContentLoading/ContentLoading.tsx} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/plugin-simple-layout",
3
- "version": "0.8.4-main.c85a9c8dae",
3
+ "version": "0.8.4-main.d05673bc65",
4
4
  "description": "Simple layout plugin for minimal UI contexts like popover windows.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -31,20 +31,20 @@
31
31
  "@radix-ui/react-context": "1.1.1",
32
32
  "@tauri-apps/plugin-deep-link": "^2.2.0",
33
33
  "@tauri-apps/plugin-haptics": "^2.3.2",
34
- "@dxos/async": "0.8.4-main.c85a9c8dae",
35
- "@dxos/echo": "0.8.4-main.c85a9c8dae",
36
- "@dxos/app-framework": "0.8.4-main.c85a9c8dae",
37
- "@dxos/log": "0.8.4-main.c85a9c8dae",
38
- "@dxos/plugin-graph": "0.8.4-main.c85a9c8dae",
39
- "@dxos/operation": "0.8.4-main.c85a9c8dae",
40
- "@dxos/react-ui-attention": "0.8.4-main.c85a9c8dae",
41
- "@dxos/react-ui-menu": "0.8.4-main.c85a9c8dae",
42
- "@dxos/react-ui-searchlist": "0.8.4-main.c85a9c8dae",
43
- "@dxos/react-ui-mosaic": "0.8.4-main.c85a9c8dae",
44
- "@dxos/react-ui-stack": "0.8.4-main.c85a9c8dae",
45
- "@dxos/schema": "0.8.4-main.c85a9c8dae",
46
- "@dxos/app-toolkit": "0.8.4-main.c85a9c8dae",
47
- "@dxos/util": "0.8.4-main.c85a9c8dae"
34
+ "@dxos/app-toolkit": "0.8.4-main.d05673bc65",
35
+ "@dxos/echo": "0.8.4-main.d05673bc65",
36
+ "@dxos/async": "0.8.4-main.d05673bc65",
37
+ "@dxos/app-framework": "0.8.4-main.d05673bc65",
38
+ "@dxos/log": "0.8.4-main.d05673bc65",
39
+ "@dxos/react-ui-attention": "0.8.4-main.d05673bc65",
40
+ "@dxos/operation": "0.8.4-main.d05673bc65",
41
+ "@dxos/plugin-graph": "0.8.4-main.d05673bc65",
42
+ "@dxos/react-ui-menu": "0.8.4-main.d05673bc65",
43
+ "@dxos/react-ui-mosaic": "0.8.4-main.d05673bc65",
44
+ "@dxos/react-ui-searchlist": "0.8.4-main.d05673bc65",
45
+ "@dxos/react-ui-stack": "0.8.4-main.d05673bc65",
46
+ "@dxos/schema": "0.8.4-main.d05673bc65",
47
+ "@dxos/util": "0.8.4-main.d05673bc65"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/react": "~19.2.7",
@@ -53,23 +53,23 @@
53
53
  "react": "~19.2.3",
54
54
  "react-dom": "~19.2.3",
55
55
  "vite": "^7.1.11",
56
- "@dxos/plugin-client": "0.8.4-main.c85a9c8dae",
57
- "@dxos/plugin-search": "0.8.4-main.c85a9c8dae",
58
- "@dxos/app-graph": "0.8.4-main.c85a9c8dae",
59
- "@dxos/plugin-space": "0.8.4-main.c85a9c8dae",
60
- "@dxos/plugin-testing": "0.8.4-main.c85a9c8dae",
61
- "@dxos/react-ui": "0.8.4-main.c85a9c8dae",
62
- "@dxos/plugin-preview": "0.8.4-main.c85a9c8dae",
63
- "@dxos/storybook-utils": "0.8.4-main.c85a9c8dae",
64
- "@dxos/schema": "0.8.4-main.c85a9c8dae",
65
- "@dxos/ui-theme": "0.8.4-main.c85a9c8dae"
56
+ "@dxos/app-graph": "0.8.4-main.d05673bc65",
57
+ "@dxos/plugin-preview": "0.8.4-main.d05673bc65",
58
+ "@dxos/plugin-client": "0.8.4-main.d05673bc65",
59
+ "@dxos/plugin-search": "0.8.4-main.d05673bc65",
60
+ "@dxos/plugin-space": "0.8.4-main.d05673bc65",
61
+ "@dxos/plugin-testing": "0.8.4-main.d05673bc65",
62
+ "@dxos/schema": "0.8.4-main.d05673bc65",
63
+ "@dxos/react-ui": "0.8.4-main.d05673bc65",
64
+ "@dxos/ui-theme": "0.8.4-main.d05673bc65",
65
+ "@dxos/storybook-utils": "0.8.4-main.d05673bc65"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "effect": "3.19.16",
69
69
  "react": "~19.2.3",
70
70
  "react-dom": "~19.2.3",
71
- "@dxos/ui-theme": "0.8.4-main.c85a9c8dae",
72
- "@dxos/react-ui": "0.8.4-main.c85a9c8dae"
71
+ "@dxos/ui-theme": "0.8.4-main.d05673bc65",
72
+ "@dxos/react-ui": "0.8.4-main.d05673bc65"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
@@ -5,21 +5,14 @@
5
5
  import * as Effect from 'effect/Effect';
6
6
 
7
7
  import { Capabilities, Capability } from '@dxos/app-framework';
8
- import { LayoutOperation } from '@dxos/app-toolkit';
8
+ import { getCompanionVariant, LayoutOperation, isPinnedWorkspace } from '@dxos/app-toolkit';
9
9
  import { Operation, OperationResolver } from '@dxos/operation';
10
- import { ATTENDABLE_PATH_SEPARATOR } from '@dxos/react-ui-attention';
11
10
 
12
11
  import { type SimpleLayoutState, SimpleLayoutState as SimpleLayoutStateCapability } from '../../types';
13
12
 
14
13
  /** Maximum number of items to keep in navigation history. */
15
14
  const MAX_HISTORY_LENGTH = 50;
16
15
 
17
- /** Parse entry ID to extract primary ID and variant. */
18
- const parseEntryId = (entryId: string) => {
19
- const [id, variant] = entryId.split(ATTENDABLE_PATH_SEPARATOR);
20
- return { id, variant };
21
- };
22
-
23
16
  export default Capability.makeModule(
24
17
  Effect.fnUntraced(function* () {
25
18
  const registry = yield* Capability.get(Capabilities.AtomRegistry);
@@ -51,6 +44,7 @@ export default Capability.makeModule(
51
44
  //
52
45
  // UpdateComplementary - Controls companion drawer.
53
46
  //
47
+ // TODO(wittjosiah): Not sure if we should be using this for the drawer.
54
48
  OperationResolver.make({
55
49
  operation: LayoutOperation.UpdateComplementary,
56
50
  handler: Effect.fnUntraced(function* (input) {
@@ -59,6 +53,13 @@ export default Capability.makeModule(
59
53
  ...state,
60
54
  drawerState: 'closed',
61
55
  }));
56
+ } else if (input.subject) {
57
+ const variant = getCompanionVariant(input.subject);
58
+ updateState((state) => ({
59
+ ...state,
60
+ companionVariant: variant,
61
+ drawerState: input.state === 'expanded' ? 'expanded' : 'open',
62
+ }));
62
63
  }
63
64
  }),
64
65
  }),
@@ -116,7 +117,7 @@ export default Capability.makeModule(
116
117
  ...state,
117
118
  // TODO(wittjosiah): This is a hack to prevent the previous deck from being set for pinned items.
118
119
  // Ideally this should be worked into the data model in a generic way.
119
- previousWorkspace: !state.workspace.startsWith('!') ? state.workspace : state.previousWorkspace,
120
+ previousWorkspace: !isPinnedWorkspace(state.workspace) ? state.workspace : state.previousWorkspace,
120
121
  workspace: input.subject,
121
122
  active: undefined,
122
123
  // Clear history when switching workspaces.
@@ -145,32 +146,16 @@ export default Capability.makeModule(
145
146
  operation: LayoutOperation.Open,
146
147
  handler: Effect.fnUntraced(function* (input) {
147
148
  const id = input.subject[0];
148
- const { id: primaryId, variant } = parseEntryId(id);
149
- const state = getState();
150
-
151
- // Only treat as companion when opening a variant of the current workspace/active (e.g. object~comments).
152
- // IDs like settings~spaceId are alternate-tree nodes and should navigate main content, not open the drawer.
153
- // TODO(wittjosiah): Factor out the change-companion operation from deck to a common layout operation.
154
- const isCompanionOfCurrent = variant && (primaryId === state.workspace || primaryId === state.active);
155
- if (isCompanionOfCurrent) {
156
- updateState((state) => ({
149
+ updateState((state) => {
150
+ const newHistory = state.active ? [...state.history, state.active] : state.history;
151
+ const trimmedHistory =
152
+ newHistory.length > MAX_HISTORY_LENGTH ? newHistory.slice(-MAX_HISTORY_LENGTH) : newHistory;
153
+ return {
157
154
  ...state,
158
- companionVariant: variant,
159
- drawerState: state.drawerState === 'closed' || !state.drawerState ? 'open' : state.drawerState,
160
- }));
161
- } else {
162
- // Regular navigation - update active and history (use full id for alternate-tree nodes).
163
- updateState((state) => {
164
- const newHistory = state.active ? [...state.history, state.active] : state.history;
165
- const trimmedHistory =
166
- newHistory.length > MAX_HISTORY_LENGTH ? newHistory.slice(-MAX_HISTORY_LENGTH) : newHistory;
167
- return {
168
- ...state,
169
- active: id,
170
- history: trimmedHistory,
171
- };
172
- });
173
- }
155
+ active: id,
156
+ history: trimmedHistory,
157
+ };
158
+ });
174
159
  }),
175
160
  }),
176
161
 
@@ -9,7 +9,7 @@ import { Capabilities, Capability } from '@dxos/app-framework';
9
9
  import { Surface } from '@dxos/app-framework/ui';
10
10
  import { Node } from '@dxos/plugin-graph';
11
11
 
12
- import { Home, Workspace } from '../../components';
12
+ import { Home, NavBranch } from '../../components';
13
13
  import { meta } from '../../meta';
14
14
 
15
15
  type SurfaceData = {
@@ -23,18 +23,20 @@ export default Capability.makeModule(() =>
23
23
  Effect.succeed(
24
24
  Capability.contributes(Capabilities.ReactSurface, [
25
25
  Surface.create({
26
- id: `${meta.id}/home`,
26
+ id: `${meta.id}.home`,
27
27
  role: 'article',
28
28
  filter: (data): data is SurfaceData => data.attendableId === Node.RootId,
29
29
  component: () => <Home />,
30
30
  }),
31
31
  Surface.create({
32
- id: `${meta.id}/workspace-article`,
32
+ id: `${meta.id}.nav-branch`,
33
33
  role: 'article',
34
34
  position: 'fallback',
35
- filter: (data): data is SurfaceData =>
36
- ALLOWED_DISPOSITIONS.includes((data.properties as Record<string, any>)?.disposition),
37
- component: ({ data }) => <Workspace id={data.attendableId} />,
35
+ filter: (data): data is SurfaceData => {
36
+ const props = data.properties as Record<string, any>;
37
+ return ALLOWED_DISPOSITIONS.includes(props?.disposition) || props?.role === 'branch';
38
+ },
39
+ component: ({ data }) => <NavBranch id={data.attendableId} />,
38
40
  }),
39
41
  ]),
40
42
  ),
@@ -5,17 +5,16 @@
5
5
  import * as Effect from 'effect/Effect';
6
6
 
7
7
  import { Capabilities, Capability } from '@dxos/app-framework';
8
- import { LayoutOperation } from '@dxos/app-toolkit';
8
+ import { LayoutOperation, fromUrlPath, getWorkspaceFromPath, toUrlPath } from '@dxos/app-toolkit';
9
9
  import { log } from '@dxos/log';
10
- import { Node } from '@dxos/plugin-graph';
11
10
  import { isTauri } from '@dxos/util';
12
11
 
13
12
  import { type SimpleLayoutState, SimpleLayoutState as SimpleLayoutStateCapability } from '../../types';
14
13
 
15
14
  /**
16
15
  * URL handler for simple layout that syncs browser URL with layout state.
17
- * URL format: /{workspace} or /{workspace}/{active}
18
- * Root is represented as / or /root.
16
+ * URL paths map directly to qualified graph IDs with the leading `root` segment stripped.
17
+ * Root is represented as `/`.
19
18
  *
20
19
  * On mobile Tauri, also listens for deep links via the deep-link plugin.
21
20
  */
@@ -25,38 +24,33 @@ export default Capability.makeModule(
25
24
 
26
25
  /**
27
26
  * Handle navigation from a pathname.
28
- * Parses path and updates state accordingly.
27
+ * Restores the qualified graph ID and dispatches layout operations.
29
28
  */
30
29
  const handlePathNavigation = (pathname: string) => {
31
30
  log.info('[UrlHandler] Navigating to path', { pathname });
32
31
 
33
- // Parse URL segments: /{workspace}/{active}
34
- const [_, nextWorkspace, nextActive] = pathname.split('/');
32
+ const qualifiedId = fromUrlPath(pathname);
33
+ const workspace = getWorkspaceFromPath(qualifiedId);
35
34
 
36
- // Determine target workspace (empty or 'root' means Node.RootId).
37
- const targetWorkspace = !nextWorkspace || nextWorkspace === 'root' ? Node.RootId : nextWorkspace;
35
+ invokeSync(LayoutOperation.SwitchWorkspace, { subject: workspace });
38
36
 
39
- // Navigate via operations (they will update state accordingly).
40
- invokeSync(LayoutOperation.SwitchWorkspace, { subject: targetWorkspace });
41
- if (nextActive) {
42
- invokeSync(LayoutOperation.Open, { subject: [nextActive] });
37
+ const activeId = qualifiedId !== workspace ? qualifiedId : undefined;
38
+ if (activeId) {
39
+ invokeSync(LayoutOperation.Open, { subject: [activeId] });
43
40
  }
44
41
  };
45
42
 
46
43
  const onNavigation = handleNavigation(handlePathNavigation);
47
44
 
48
- // Handle initial URL and listen for browser navigation.
49
45
  yield* Effect.sync(() => onNavigation());
50
46
  window.addEventListener('popstate', onNavigation);
51
47
 
52
- // Set up deep link listener for mobile Tauri.
53
48
  let unlistenDeepLink: (() => void) | undefined;
54
49
  if (isTauri()) {
55
50
  yield* Effect.tryPromise({
56
51
  try: async () => {
57
52
  const { getCurrent, onOpenUrl } = await import('@tauri-apps/plugin-deep-link');
58
53
 
59
- // Check if app was launched via deep link (cold start).
60
54
  const launchUrls = await getCurrent();
61
55
  if (launchUrls && launchUrls.length > 0) {
62
56
  log.info('[UrlHandler] App launched with deep links', { urls: launchUrls });
@@ -65,7 +59,6 @@ export default Capability.makeModule(
65
59
  }
66
60
  }
67
61
 
68
- // Listen for deep links while app is running.
69
62
  unlistenDeepLink = await onOpenUrl((urls) => {
70
63
  log.info('[UrlHandler] Deep links received', { urls });
71
64
  for (const url of urls) {
@@ -82,18 +75,16 @@ export default Capability.makeModule(
82
75
  }).pipe(Effect.catchAll(() => Effect.void));
83
76
  }
84
77
 
85
- // Subscribe to state changes to update the URL.
86
78
  let lastWorkspace: string | undefined;
87
79
  let lastActive: string | undefined;
88
80
  const unsubscribe = yield* Capabilities.subscribeAtom(SimpleLayoutStateCapability, (state: SimpleLayoutState) => {
89
81
  const { workspace, active } = state;
90
82
 
91
- // Only update URL if relevant state changed.
92
83
  if (workspace !== lastWorkspace || active !== lastActive) {
93
84
  lastWorkspace = workspace;
94
85
  lastActive = active;
95
86
 
96
- const path = pathFromState(workspace, active);
87
+ const path = active ? toUrlPath(active) : toUrlPath(workspace);
97
88
  if (window.location.pathname !== path) {
98
89
  history.pushState(null, '', `${path}${window.location.search}`);
99
90
  }
@@ -110,27 +101,12 @@ export default Capability.makeModule(
110
101
  }),
111
102
  );
112
103
 
113
- // TODO(wittjosiah): Instead of hardcoding redirect paths, we should either:
114
- // 1. Validate that the workspace exists in the graph before navigating.
115
- // 2. Implement more structured routing with explicit route definitions.
116
104
  /**
117
105
  * Check if a path is a special redirect path that shouldn't be navigated to.
118
106
  * These paths are handled by other systems (e.g., OAuth).
119
107
  */
120
108
  const isRedirectPath = (pathname: string): boolean => pathname.startsWith('/redirect/');
121
109
 
122
- /**
123
- * Build pathname from layout state. Root workspace is / or /root/{active}.
124
- */
125
- const pathFromState = (workspace: string, active: string | undefined): string =>
126
- workspace === Node.RootId
127
- ? active
128
- ? `/${Node.RootId}/${active}`
129
- : '/'
130
- : active
131
- ? `/${workspace}/${active}`
132
- : `/${workspace}`;
133
-
134
110
  /**
135
111
  * Returns a handler for navigation events (initial load and popstate) that navigates to current pathname.
136
112
  */
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './ContentLoading';
@@ -16,7 +16,7 @@ import { mx } from '@dxos/ui-theme';
16
16
  import { byPosition } from '@dxos/util';
17
17
 
18
18
  import { meta } from '../../meta';
19
- import { useLoadDescendents } from '../hooks';
19
+ import { useExpandPath } from '../hooks';
20
20
 
21
21
  export type HomeProps = {};
22
22
 
@@ -28,7 +28,7 @@ export const Home = (_: HomeProps) => {
28
28
  const userAccountItem = useItemsByDisposition('user-account')[0];
29
29
  const pinnedItems = useItemsByDisposition('pin-end', true);
30
30
  const workspaceItems = useItemsByDisposition('workspace');
31
- useLoadDescendents(Node.RootId);
31
+ useExpandPath(Node.RootId);
32
32
 
33
33
  const items = useMemo(
34
34
  () => [...(userAccountItem ? [userAccountItem] : []), ...pinnedItems, ...workspaceItems],
@@ -73,7 +73,7 @@ const WorkspaceTile: MosaicStackTileComponent<Node.Node> = (props) => {
73
73
  const isSelected = selectedValue === data.id;
74
74
  const cardRef = useRef<HTMLDivElement>(null);
75
75
 
76
- useLoadDescendents(data.id);
76
+ useExpandPath(data.id);
77
77
 
78
78
  const handleSelect = useCallback(
79
79
  () => invokePromise(LayoutOperation.SwitchWorkspace, { subject: data.id }),
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useEffect, useRef } from 'react';
5
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
6
6
 
7
7
  import { useOperationInvoker } from '@dxos/app-framework/ui';
8
8
  import { LayoutOperation } from '@dxos/app-toolkit';
@@ -15,29 +15,33 @@ import { SearchList, useSearchListItem, useSearchListResults } from '@dxos/react
15
15
  import { mx } from '@dxos/ui-theme';
16
16
 
17
17
  import { meta } from '../../meta';
18
- import { useLoadDescendents } from '../hooks';
18
+ import { useExpandPath } from '../hooks';
19
19
 
20
- export type WorkspaceProps = {
20
+ export type NavBranchProps = {
21
21
  id: string;
22
22
  };
23
23
 
24
24
  /**
25
- * Displays the contents of a workspace disposition graph node as a searchable list.
26
- * Shows direct children of the workspace with icons and labels,
27
- * allowing users to filter via search and navigate by selecting an item.
25
+ * Renders the children of a graph branch node as a searchable mosaic list.
26
+ * Used for any node with `role: 'branch'` or a workspace disposition, including
27
+ * spaces, collection sections, type sections, and schema nodes.
28
28
  */
29
- export const Workspace = ({ id }: WorkspaceProps) => {
29
+ export const NavBranch = ({ id }: NavBranchProps) => {
30
30
  const { t } = useTranslation(meta.id);
31
31
  const { graph } = useAppGraph();
32
32
 
33
- // Expand the workspace node to load its children.
34
- useLoadDescendents(id);
33
+ useExpandPath(id);
35
34
 
36
- // Get direct children of the workspace node.
37
35
  const children = useConnections(graph, id, 'child');
38
36
 
37
+ // TODO(wittjosiah): Move alternate-tree nodes to a non-child relation so they don't need filtering.
38
+ const visibleChildren = useMemo(
39
+ () => children.filter((node) => node.properties.disposition !== 'alternate-tree'),
40
+ [children],
41
+ );
42
+
39
43
  const { results, handleSearch } = useSearchListResults({
40
- items: children,
44
+ items: visibleChildren,
41
45
  extract: (child) => toLocalizedString(child.properties.label, t),
42
46
  });
43
47
 
@@ -46,6 +50,7 @@ export const Workspace = ({ id }: WorkspaceProps) => {
46
50
  <Panel.Root>
47
51
  <Panel.Toolbar asChild>
48
52
  <Toolbar.Root>
53
+ {/* TODO(wittjosiah): Search should be pluggable. Must support searching via ECHO query inside a space. */}
49
54
  <SearchList.Input placeholder={t('search placeholder')} autoFocus />
50
55
  </Toolbar.Root>
51
56
  </Panel.Toolbar>
@@ -54,7 +59,7 @@ export const Workspace = ({ id }: WorkspaceProps) => {
54
59
  <Mosaic.Container asChild>
55
60
  <ScrollArea.Root orientation='vertical'>
56
61
  <ScrollArea.Viewport classNames='p-2'>
57
- <Mosaic.Stack items={results} getId={(child) => child.id} Tile={WorkspaceChildTile} />
62
+ <Mosaic.Stack items={results} getId={(child) => child.id} Tile={NavBranchTile} />
58
63
  </ScrollArea.Viewport>
59
64
  </ScrollArea.Root>
60
65
  </Mosaic.Container>
@@ -65,7 +70,7 @@ export const Workspace = ({ id }: WorkspaceProps) => {
65
70
  );
66
71
  };
67
72
 
68
- const WorkspaceChildTile: MosaicStackTileComponent<Node.Node> = (props) => {
73
+ const NavBranchTile: MosaicStackTileComponent<Node.Node> = (props) => {
69
74
  const data = props.data;
70
75
  const { t } = useTranslation(meta.id);
71
76
  const { invokeSync } = useOperationInvoker();
@@ -2,4 +2,4 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- export * from './Workspace';
5
+ export * from './NavBranch';
@@ -5,10 +5,10 @@
5
5
  import React, { useMemo } from 'react';
6
6
 
7
7
  import { Surface } from '@dxos/app-framework/ui';
8
+ import { getCompanionVariant } from '@dxos/app-toolkit';
8
9
  import { useAppGraph } from '@dxos/app-toolkit/ui';
9
10
  import { type Node, useNode } from '@dxos/plugin-graph';
10
11
  import { ErrorFallback, Panel } from '@dxos/react-ui';
11
- import { ATTENDABLE_PATH_SEPARATOR } from '@dxos/react-ui-attention';
12
12
  import { Menu, useMenuActions } from '@dxos/react-ui-menu';
13
13
 
14
14
  import { useCompanions, useDrawerActions, useSimpleLayoutState } from '../../hooks';
@@ -67,12 +67,6 @@ export const Drawer = () => {
67
67
 
68
68
  Drawer.displayName = DRAWER_NAME;
69
69
 
70
- /** Parse entry ID to extract primary ID and variant. */
71
- const parseEntryId = (entryId: string) => {
72
- const [id, variant] = entryId.split(ATTENDABLE_PATH_SEPARATOR);
73
- return { id, variant };
74
- };
75
-
76
70
  /**
77
71
  * Resolves which companion to show based on variant preference.
78
72
  * Falls back to first available if preferred variant not available.
@@ -85,10 +79,7 @@ const useSelectedCompanion = (companions: Node.Node[], preferredVariant?: string
85
79
 
86
80
  // Try to find companion matching the preferred variant.
87
81
  if (preferredVariant) {
88
- const preferred = companions.find((c) => {
89
- const { variant } = parseEntryId(c.id);
90
- return variant === preferredVariant;
91
- });
82
+ const preferred = companions.find((c) => getCompanionVariant(c.id) === preferredVariant);
92
83
  if (preferred) {
93
84
  return preferred;
94
85
  }
@@ -99,7 +90,7 @@ const useSelectedCompanion = (companions: Node.Node[], preferredVariant?: string
99
90
  }, [companions, preferredVariant]);
100
91
 
101
92
  const companionId = selectedCompanion?.id;
102
- const { variant } = parseEntryId(companionId ?? '');
93
+ const variant = companionId ? getCompanionVariant(companionId) : undefined;
103
94
 
104
95
  return { selectedCompanion, companionId, variant };
105
96
  };
@@ -13,8 +13,8 @@ import { mx } from '@dxos/ui-theme';
13
13
 
14
14
  import { useAppBarProps, useNavbarActions, useSimpleLayoutState } from '../../hooks';
15
15
  import { ContentLoading } from '../ContentLoading';
16
- import { useLoadDescendents } from '../hooks';
17
- import { useMobileLayout } from '../MobileLayout/MobileLayout';
16
+ import { useExpandPath } from '../hooks';
17
+ import { useMobileLayout } from '../MobileLayout';
18
18
 
19
19
  import { AppBar } from './AppBar';
20
20
  import { NavBar } from './NavBar';
@@ -47,8 +47,7 @@ export const Main = () => {
47
47
  );
48
48
  }, [id, node, node?.data, node?.properties, state.popoverAnchorId]);
49
49
 
50
- // Ensures that children are loaded so that they are available to navigate to.
51
- useLoadDescendents(id);
50
+ useExpandPath(id);
52
51
 
53
52
  // TODO(burdon): BUG: When showing ANY statusbar the size progressively shrinks when the keyboard opens/closes.
54
53
  const showNavBar = !keyboardOpen && !state.isPopover && state.drawerState === 'closed';
@@ -4,23 +4,23 @@
4
4
 
5
5
  import { useEffect } from 'react';
6
6
 
7
+ import { expandAttendableId } from '@dxos/react-ui-attention';
7
8
  import { useAppGraph } from '@dxos/app-toolkit/ui';
8
9
  import { Graph } from '@dxos/plugin-graph';
9
10
 
10
11
  /**
11
- * Hook to expand graph nodes two levels deep when directly linked to.
12
+ * Expand graph nodes along the full path from root to the given node ID.
13
+ * Walks each progressive prefix, ensuring ancestor nodes are materialized
14
+ * before attempting to access their children.
12
15
  */
13
- export const useLoadDescendents = (nodeId?: string) => {
16
+ export const useExpandPath = (nodeId?: string) => {
14
17
  const { graph } = useAppGraph();
15
18
 
16
19
  useEffect(() => {
17
20
  if (nodeId) {
18
- // First level: expand the node itself.
19
- Graph.expand(graph, nodeId, 'child');
20
- // Second level: expand each child.
21
- Graph.getConnections(graph, nodeId, 'child').forEach((child) => {
22
- Graph.expand(graph, child.id, 'child');
23
- });
21
+ for (const prefix of expandAttendableId(nodeId)) {
22
+ Graph.expand(graph, prefix, 'child');
23
+ }
24
24
  }
25
25
  }, [nodeId, graph]);
26
26
  };
@@ -7,4 +7,4 @@ export * from './Home';
7
7
  export * from './MobileLayout';
8
8
  export * from './Popover';
9
9
  export * from './SimpleLayout';
10
- export * from './Workspace';
10
+ export * from './NavBranch';
@@ -5,25 +5,23 @@
5
5
  import { type Atom } from '@effect-atom/atom-react';
6
6
  import * as Effect from 'effect/Effect';
7
7
 
8
- import { type Capabilities } from '@dxos/app-framework';
9
- import { type AppCapabilities, LayoutOperation } from '@dxos/app-toolkit';
8
+ import { type AppCapabilities, getCompanionVariant } from '@dxos/app-toolkit';
10
9
  import { Node } from '@dxos/plugin-graph';
11
- import { ATTENDABLE_PATH_SEPARATOR } from '@dxos/react-ui-attention';
12
10
  import { type ActionGraphProps } from '@dxos/react-ui-menu';
13
11
  import { byPosition } from '@dxos/util';
14
12
 
15
13
  import { type SimpleLayoutState } from '../types';
16
14
 
17
15
  // TODO(wittjosiah): Factor out to shared location with plugin-deck.
18
- export const PLANK_COMPANION_TYPE = 'dxos.org/plugin/deck/plank-companion';
16
+ export const PLANK_COMPANION_TYPE = 'org.dxos.plugin.deck.plank-companion';
19
17
 
20
18
  export type CompanionActionsConfig = {
21
19
  /** Prefix for companion action IDs (e.g. 'navbar' or 'drawer') */
22
20
  idPrefix: string;
23
21
  /** Optional: highlight companion with this variant */
24
22
  selectedVariant?: string;
25
- /** invokeSync function for dispatching operations */
26
- invokeSync: Capabilities.OperationInvoker['invokeSync'];
23
+ /** State updater for toggling the drawer. */
24
+ updateState: (fn: (state: SimpleLayoutState) => SimpleLayoutState) => void;
27
25
  };
28
26
 
29
27
  /**
@@ -37,7 +35,7 @@ export const createCompanionActions = (
37
35
  get: (atom: Atom.Atom<any>) => any,
38
36
  config: CompanionActionsConfig,
39
37
  ): Pick<ActionGraphProps, 'nodes' | 'edges'> => {
40
- const { idPrefix, selectedVariant, invokeSync } = config;
38
+ const { idPrefix, selectedVariant, updateState } = config;
41
39
 
42
40
  // Derive activeId from state atom.
43
41
  const state = get(stateAtom);
@@ -52,12 +50,8 @@ export const createCompanionActions = (
52
50
  const nodes: ActionGraphProps['nodes'] = [];
53
51
  const edges: ActionGraphProps['edges'] = [];
54
52
 
55
- // Add companion actions.
56
- // TODO(burdon): Cap at 6 items.
57
53
  companions.forEach((companion: Node.Node) => {
58
- // Extract variant for highlighting if needed.
59
- const [, companionVariant] = companion.id.split(ATTENDABLE_PATH_SEPARATOR);
60
-
54
+ const companionVariant = getCompanionVariant(companion.id);
61
55
  const companionAction = {
62
56
  id: `${idPrefix}-companion-${companion.id}`,
63
57
  type: Node.ActionType,
@@ -65,15 +59,19 @@ export const createCompanionActions = (
65
59
  icon: companion.properties.icon ?? 'ph--placeholder--regular',
66
60
  label: companion.properties.label,
67
61
  iconOnly: true,
68
- // Conditionally add variant highlighting.
69
62
  ...(selectedVariant !== undefined && {
70
63
  variant: selectedVariant === companionVariant ? 'primary' : 'ghost',
71
64
  }),
72
65
  },
73
66
  data: () =>
74
67
  Effect.sync(() =>
75
- invokeSync(LayoutOperation.Open, {
76
- subject: [companion.id],
68
+ updateState((current) => {
69
+ const closing = current.companionVariant === companionVariant && current.drawerState !== 'closed';
70
+ return {
71
+ ...current,
72
+ companionVariant: closing ? undefined : companionVariant,
73
+ drawerState: closing ? 'closed' : 'open',
74
+ };
77
75
  }),
78
76
  ),
79
77
  };
@@ -102,8 +102,7 @@ export const useAppBarProps = (): Omit<AppBarProps, 'classNames'> => {
102
102
  }, [graph, invokeSync, state.active, state.history.length]);
103
103
 
104
104
  // Compute popover anchor ID.
105
- const popoverAnchorId =
106
- node && state.popoverAnchorId === `dxos.org/ui/${meta.id}/${node.id}` ? state.popoverAnchorId : undefined;
105
+ const popoverAnchorId = node && state.popoverAnchorId === `${meta.id}:${node.id}` ? state.popoverAnchorId : undefined;
107
106
 
108
107
  return {
109
108
  title,