@astrale-os/shell-react 0.4.0-beta.13 → 0.4.0-beta.15

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 (37) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/application/apps/app-host.d.ts +2 -2
  3. package/dist/application/apps/app-host.js +1 -1
  4. package/dist/application/apps/open-app.hook.d.ts +2 -2
  5. package/dist/application/apps/open-app.hook.js +1 -1
  6. package/dist/application/index.d.ts +1 -9
  7. package/dist/application/index.js +0 -8
  8. package/dist/application/user/user.hook.d.ts +2 -2
  9. package/dist/application/user/user.store.d.ts +2 -4
  10. package/dist/application/user/user.store.js +1 -10
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +1 -1
  13. package/dist/schema/action.hook.d.ts +5 -5
  14. package/dist/schema/action.hook.js +6 -2
  15. package/package.json +4 -4
  16. package/src/application/apps/app-host.tsx +3 -3
  17. package/src/application/apps/open-app.hook.ts +3 -3
  18. package/src/application/index.ts +1 -13
  19. package/src/application/user/user.hook.ts +2 -2
  20. package/src/application/user/user.store.ts +3 -14
  21. package/src/index.ts +2 -21
  22. package/src/schema/action.hook.ts +11 -7
  23. package/dist/application/apps/apps.hook.d.ts +0 -7
  24. package/dist/application/apps/apps.hook.js +0 -19
  25. package/dist/application/spaces/current-space.hook.d.ts +0 -6
  26. package/dist/application/spaces/current-space.hook.js +0 -27
  27. package/dist/application/spaces/current-space.store.d.ts +0 -31
  28. package/dist/application/spaces/current-space.store.js +0 -79
  29. package/dist/application/spaces/space.hook.d.ts +0 -4
  30. package/dist/application/spaces/space.hook.js +0 -28
  31. package/dist/application/spaces/spaces.hook.d.ts +0 -12
  32. package/dist/application/spaces/spaces.hook.js +0 -89
  33. package/src/application/apps/apps.hook.ts +0 -38
  34. package/src/application/spaces/current-space.hook.ts +0 -43
  35. package/src/application/spaces/current-space.store.ts +0 -110
  36. package/src/application/spaces/space.hook.ts +0 -45
  37. package/src/application/spaces/spaces.hook.ts +0 -118
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0-beta.15](https://github.com/astrale-os/shell/compare/shell-react-v0.4.0-beta.14...shell-react-v0.4.0-beta.15) (2026-09-07)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **react:** preflight exact-class instance method targets ([#182](https://github.com/astrale-os/shell/issues/182))
9
+ * **runtime:** simplify Shell users, groups and invitations ([#187](https://github.com/astrale-os/shell/issues/187))
10
+
11
+ ### Features
12
+
13
+ * **domain:** migrate Shell to project environments ([#183](https://github.com/astrale-os/shell/issues/183)) ([ec72b5d](https://github.com/astrale-os/shell/commit/ec72b5d11fea5ff3266d1fd0a823c4eff5cc6d3e))
14
+ * **react:** preflight exact-class instance method targets ([#182](https://github.com/astrale-os/shell/issues/182)) ([305e92f](https://github.com/astrale-os/shell/commit/305e92f0578309e5e94d8fd78d874f7c5a60ee46))
15
+ * **runtime:** simplify Shell users, groups and invitations ([#187](https://github.com/astrale-os/shell/issues/187)) ([7f00279](https://github.com/astrale-os/shell/commit/7f002791edae81944e6e80940834dee49212aa26))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **react:** require the Shell User and Group API release ([#196](https://github.com/astrale-os/shell/issues/196)) ([df34749](https://github.com/astrale-os/shell/commit/df3474972a54c30816a02fc3510b478299bea4f1))
21
+
22
+ ## [0.4.0-beta.14](https://github.com/astrale-os/shell/compare/shell-react-v0.4.0-beta.13...shell-react-v0.4.0-beta.14) (2026-09-04)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **deps:** align SDK and Kernel release cohort ([#177](https://github.com/astrale-os/shell/issues/177)) ([294ec8f](https://github.com/astrale-os/shell/commit/294ec8f6f7a908afb608a1078e03a84ff2cc09ca))
28
+
3
29
  ## [0.4.0-beta.13](https://github.com/astrale-os/shell/compare/shell-react-v0.4.0-beta.12...shell-react-v0.4.0-beta.13) (2026-09-03)
4
30
 
5
31
 
@@ -1,5 +1,5 @@
1
1
  import type { Key } from '@astrale-os/sdk/schema';
2
- import type { App, HostCapabilities, ResolvedView } from '@astrale-os/shell';
2
+ import type { HostCapabilities, ResolvedView } from '@astrale-os/shell';
3
3
  import type { CSSProperties, ReactNode } from 'react';
4
4
  import type { ViewHostHandle } from '../../window/host.js';
5
5
  import type { WindowState } from '../../window/lifecycle/states.js';
@@ -9,7 +9,7 @@ export type AppHostFallbackState = WindowState | {
9
9
  readonly phase: 'no-view';
10
10
  };
11
11
  export interface AppHostProps {
12
- app: App | Key.Origin;
12
+ app: Key.Origin;
13
13
  /** Override the installed Domain entry View. */
14
14
  view?: ResolvedView;
15
15
  capabilities?: HostCapabilities;
@@ -7,7 +7,7 @@ export const AppHost = forwardRef(function AppHost(props, ref) {
7
7
  const open = useOpenApp();
8
8
  const [opened, setOpened] = useState(undefined);
9
9
  const [error, setError] = useState(undefined);
10
- const origin = typeof props.app === 'string' ? props.app : props.app.origin;
10
+ const origin = props.app;
11
11
  const openRef = useRef(open);
12
12
  openRef.current = open;
13
13
  useEffect(() => {
@@ -1,5 +1,5 @@
1
1
  import type { Key } from '@astrale-os/sdk/schema';
2
- import type { App, ResolvedView } from '@astrale-os/shell';
2
+ import type { ResolvedView } from '@astrale-os/shell';
3
3
  import type { Action } from '../../graph/action.js';
4
4
  /** Resolve one installed Domain's ordinary entry View. */
5
- export declare function useOpenApp(): Action<[App | Key.Origin], ResolvedView | null>;
5
+ export declare function useOpenApp(): Action<[Key.Origin], ResolvedView | null>;
@@ -3,5 +3,5 @@ import { useShell } from '../../session/shell.hook.js';
3
3
  /** Resolve one installed Domain's ordinary entry View. */
4
4
  export function useOpenApp() {
5
5
  const shell = useShell();
6
- return useAction((app) => shell.apps.open(app));
6
+ return useAction((origin) => shell.views.entrypoint(origin));
7
7
  }
@@ -1,15 +1,7 @@
1
1
  export { useUser } from './user/user.hook.js';
2
- export { useSpaces } from './spaces/spaces.hook.js';
3
- export type { SpacesResult } from './spaces/spaces.hook.js';
4
- export { useCurrentSpace } from './spaces/current-space.hook.js';
5
- export type { CurrentSpaceResult } from './spaces/current-space.hook.js';
6
- export { useSpace } from './spaces/space.hook.js';
7
- export type { SpaceResult } from './spaces/space.hook.js';
8
- export { useApps } from './apps/apps.hook.js';
9
- export type { AppsResult } from './apps/apps.hook.js';
10
2
  export { useOpenApp } from './apps/open-app.hook.js';
11
3
  export { AppHost } from './apps/app-host.js';
12
4
  export type { AppHostFallbackState, AppHostProps } from './apps/app-host.js';
13
5
  export { AppLogo } from './apps/app-logo.js';
14
6
  export type { AppLogoProps } from './apps/app-logo.js';
15
- export type { App, NodeTarget, ResolvedView, SpaceNode, UserNode } from '@astrale-os/shell';
7
+ export type { NodeTarget, ResolvedView, UserNode } from '@astrale-os/shell';
@@ -1,12 +1,4 @@
1
- // application — API §12.2. The OS-plane hooks over the shell package's
2
- // `shell.user/spaces/apps` namespaces + the window module: useUser, useSpaces,
3
- // useCurrentSpace, useSpace, useApps, useOpenApp,
4
- // AppHost, AppLogo.
5
1
  export { useUser } from './user/user.hook.js';
6
- export { useSpaces } from './spaces/spaces.hook.js';
7
- export { useCurrentSpace } from './spaces/current-space.hook.js';
8
- export { useSpace } from './spaces/space.hook.js';
9
- export { useApps } from './apps/apps.hook.js';
10
2
  export { useOpenApp } from './apps/open-app.hook.js';
11
3
  export { AppHost } from './apps/app-host.js';
12
4
  export { AppLogo } from './apps/app-logo.js';
@@ -1,6 +1,6 @@
1
1
  /**
2
- * useUser (API §12.2) — self as the shell-domain `User` (typed
3
- * firstName/lastName/email). The provider PRE-RESOLVES it at boot (`primeUser`),
2
+ * useUser — self as a Shell.User descendant, preserving canonical graph property
3
+ * keys. The provider PRE-RESOLVES it at boot (`primeUser`),
4
4
  * so `useUser` is synchronous below the provider (API §2). OS-plane absent → throws
5
5
  * descriptively to the nearest boundary; a never-primed shell suspends on first use
6
6
  * (§1.4). FLAG: not yet re-rendered on rename — becomes live via a memory shape.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The OS-plane user store (API §2/§12.2). Provider-boot priming (`primeUser`) seeds
3
3
  * the per-Shell user cache so `useUser` is synchronous, and tracks OS-plane
4
- * availability: a missing/rejecting `shell.user.me` marks the plane down (the other
5
- * OS hooks then read idle) without failing boot. A never-primed shell falls back to
4
+ * availability: a missing/rejecting `shell.user.me` is retained in the User cache
5
+ * without failing boot. A never-primed shell falls back to
6
6
  * a lazy cold read that SUSPENDS on first use (§1.4).
7
7
  */
8
8
  import type { Shell, UserNode } from '@astrale-os/shell';
@@ -22,8 +22,6 @@ export type UserEntry = {
22
22
  error: unknown;
23
23
  };
24
24
  export declare const userCache: WeakMap<Shell, UserEntry>;
25
- /** Is the OS plane (shell.user/spaces/apps) usable for this shell? */
26
- export declare function isOsPlaneAvailable(shell: Shell): boolean;
27
25
  /**
28
26
  * Provider-boot priming (API §2). Best-effort: seed the user cache so `useUser`
29
27
  * is synchronous, or mark the OS plane down. NEVER throws — boot must not fail on
@@ -1,13 +1,7 @@
1
1
  export const userCache = new WeakMap();
2
- /** Shells whose OS plane could not be resolved at boot — OS hooks read idle. */
3
- const osPlaneDown = new WeakSet();
4
- /** Is the OS plane (shell.user/spaces/apps) usable for this shell? */
5
- export function isOsPlaneAvailable(shell) {
6
- return !osPlaneDown.has(shell);
7
- }
8
2
  function osPlaneError(cause) {
9
3
  const detail = cause instanceof Error ? cause.message : String(cause);
10
- return new Error(`The OS plane is unavailable (useUser/useSpaces/useApps/useDomains): ` +
4
+ return new Error(`The Shell User is unavailable (useUser): ` +
11
5
  `shell.user.me() could not resolve ` +
12
6
  `— the workspace/shell domain may not be installed for this identity. (${detail})`);
13
7
  }
@@ -19,7 +13,6 @@ function osPlaneError(cause) {
19
13
  export async function primeUser(shell) {
20
14
  const me = shell.user?.me;
21
15
  if (typeof me !== 'function') {
22
- osPlaneDown.add(shell);
23
16
  userCache.set(shell, {
24
17
  status: 'unavailable',
25
18
  error: osPlaneError('shell.user.me is not a function'),
@@ -28,11 +21,9 @@ export async function primeUser(shell) {
28
21
  }
29
22
  try {
30
23
  const value = await me.call(shell.user);
31
- osPlaneDown.delete(shell);
32
24
  userCache.set(shell, { status: 'resolved', value });
33
25
  }
34
26
  catch (error) {
35
- osPlaneDown.add(shell);
36
27
  userCache.set(shell, { status: 'unavailable', error: osPlaneError(error) });
37
28
  }
38
29
  }
package/dist/index.d.ts CHANGED
@@ -20,12 +20,12 @@ export { useEmitIntent, useIntent, useSendIntent } from './intents/index.js';
20
20
  export type { EmitIntent, IntentCtx, IntentPayload, IntentReply, SendIntent, } from './intents/index.js';
21
21
  export { Can, ConnectionBoundary, CredentialBoundary, defaultViewError, defaultViewLoading, GraphBoundary, Astrale, ViewBoundary, ViewErrorBoundary, } from './components/index.js';
22
22
  export type { AstraleProps, ViewErrorBoundaryProps } from './components/index.js';
23
- export { AppHost, AppLogo, useApps, useCurrentSpace, useOpenApp, useSpace, useSpaces, useUser, } from './application/index.js';
24
- export type { AppHostFallbackState, AppHostProps, AppLogoProps, AppsResult, CurrentSpaceResult, SpaceResult, SpacesResult, } from './application/index.js';
23
+ export { AppHost, AppLogo, useOpenApp, useUser } from './application/index.js';
24
+ export type { AppHostFallbackState, AppHostProps, AppLogoProps } from './application/index.js';
25
25
  export { NodeId } from '@astrale-os/sdk/graph/node';
26
26
  export { NodePath, Path } from '@astrale-os/sdk/graph/path';
27
27
  export type { Node } from '@astrale-os/sdk/graph/node';
28
28
  export type { PathLike } from '@astrale-os/sdk/graph/path';
29
29
  export type { AuthApi, Identity, PolicyDecisionInput } from '@astrale-os/sdk/auth';
30
30
  export type { DomainBinding } from '@astrale-os/shell';
31
- export type { App, CloseResult, CredentialSnapshot, HostCapabilities, IntentRegistry, KernelClient, MountCredential, NodeTarget, Shell, ShellConfig, SpaceNode, UserNode, ViewLocation, ViewPresentation, ViewPresentationRequest, Window, } from '@astrale-os/shell';
31
+ export type { CloseResult, CredentialSnapshot, HostCapabilities, IntentRegistry, KernelClient, MountCredential, NodeTarget, Shell, ShellConfig, UserNode, ViewLocation, ViewPresentation, ViewPresentationRequest, Window, } from '@astrale-os/shell';
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { useEmitIntent, useIntent, useSendIntent } from './intents/index.js';
17
17
  // Headless composition kit.
18
18
  export { Can, ConnectionBoundary, CredentialBoundary, defaultViewError, defaultViewLoading, GraphBoundary, Astrale, ViewBoundary, ViewErrorBoundary, } from './components/index.js';
19
19
  // Shell application conveniences.
20
- export { AppHost, AppLogo, useApps, useCurrentSpace, useOpenApp, useSpace, useSpaces, useUser, } from './application/index.js';
20
+ export { AppHost, AppLogo, useOpenApp, useUser } from './application/index.js';
21
21
  // Canonical identity carriers used by View props and rich hooks.
22
22
  export { NodeId } from '@astrale-os/sdk/graph/node';
23
23
  export { NodePath, Path } from '@astrale-os/sdk/graph/path';
@@ -2,14 +2,14 @@ import type { SessionRequestOptions } from '@astrale-os/sdk/client/session';
2
2
  import type { NodeId } from '@astrale-os/sdk/graph/node';
3
3
  import type { QueryAST, QueryDefinition } from '@astrale-os/sdk/query';
4
4
  import type { Domain } from '@astrale-os/sdk/schema';
5
- import type { CallableInputOf, CallableResultOf, MethodInheritance, ResolvedFunction, ResolvedMethod } from '@astrale-os/sdk/schema';
6
- type ExecutableInheritance = Exclude<MethodInheritance, 'abstract'>;
7
- type InstanceMethod = ResolvedMethod<unknown, unknown, false, ExecutableInheritance>;
8
- type StaticMethod = ResolvedMethod<unknown, unknown, true, ExecutableInheritance>;
5
+ import type { CallableInputOf, CallableResultOf, ClassKey, ResolvedFunction, ResolvedMethod } from '@astrale-os/sdk/schema';
6
+ type InstanceMethod = ResolvedMethod<unknown, unknown, false, false>;
7
+ type StaticMethod = ResolvedMethod<unknown, unknown, true, false>;
9
8
  type Callable = ResolvedFunction | InstanceMethod | StaticMethod;
10
- /** Stable identity of an instance Method receiver; the Kernel resolves and authorizes its Class. */
9
+ /** Receiver identity with optional observed Class for early rejection; the Kernel checks live Class. */
11
10
  export interface ActionReceiver {
12
11
  readonly id: NodeId;
12
+ readonly class?: ClassKey;
13
13
  }
14
14
  declare const QUERY_REFRESH: unique symbol;
15
15
  export interface QueryRefresh {
@@ -41,7 +41,7 @@ export function useAction(callable, options = {}) {
41
41
  const { refresh: _refresh, onError: _onError, ...request } = policy;
42
42
  try {
43
43
  const result = isInstanceMethod(current)
44
- ? await kernel.session.invoke(reference(bindingRef.current.domain, current)(receiverPath(args[0])), args[1], request)
44
+ ? await kernel.session.invoke(reference(bindingRef.current.domain, current)(receiverPath(args[0], current)), args[1], request)
45
45
  : await kernel.session.invoke(reference(bindingRef.current.domain, current), args[0], request);
46
46
  const refresh = policy.refresh ?? 'all';
47
47
  if (refresh === 'all')
@@ -80,7 +80,11 @@ function projectAction(action) {
80
80
  function isInstanceMethod(callable) {
81
81
  return 'owner' in callable && callable.static === false;
82
82
  }
83
- function receiverPath(receiver) {
83
+ function receiverPath(receiver, method) {
84
+ const observedClass = receiver.class;
85
+ if (observedClass !== undefined && observedClass !== method.owner.key) {
86
+ throw new TypeError(`Method ${method.key} requires exact receiver Class ${method.owner.key}.`);
87
+ }
84
88
  return Path.id(receiver.id);
85
89
  }
86
90
  function queryRefreshIdentity(definition) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/shell-react",
3
- "version": "0.4.0-beta.13",
3
+ "version": "0.4.0-beta.15",
4
4
  "description": "Astrale shell-react — the React face of the shell (provider, hooks, view components)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,12 +26,12 @@
26
26
  "registry": "https://registry.npmjs.org"
27
27
  },
28
28
  "dependencies": {
29
- "@astrale-os/shell": ">=0.5.0-beta.13 <1.0.0",
29
+ "@astrale-os/shell": ">=0.5.0-beta.15 <1.0.0",
30
30
  "jose": "^6.2.8",
31
31
  "zod": "4.4.3"
32
32
  },
33
33
  "devDependencies": {
34
- "@astrale-os/sdk": "0.5.0-beta.106",
34
+ "@astrale-os/sdk": "0.5.0-beta.123",
35
35
  "@testing-library/dom": "^10.4.0",
36
36
  "@testing-library/react": "^16.3.0",
37
37
  "@types/node": "^26.2.0",
@@ -46,7 +46,7 @@
46
46
  "vitest": "4.1.10"
47
47
  },
48
48
  "peerDependencies": {
49
- "@astrale-os/sdk": ">=0.5.0-beta.106 <1.0.0",
49
+ "@astrale-os/sdk": ">=0.5.0-beta.123 <1.0.0",
50
50
  "react": ">=18",
51
51
  "react-dom": ">=18"
52
52
  },
@@ -1,5 +1,5 @@
1
1
  import type { Key } from '@astrale-os/sdk/schema'
2
- import type { App, HostCapabilities, ResolvedView } from '@astrale-os/shell'
2
+ import type { HostCapabilities, ResolvedView } from '@astrale-os/shell'
3
3
  import type { CSSProperties, ReactElement, ReactNode } from 'react'
4
4
 
5
5
  import { forwardRef, useEffect, useRef, useState } from 'react'
@@ -16,7 +16,7 @@ export type AppHostFallbackState =
16
16
  | { readonly phase: 'no-view' }
17
17
 
18
18
  export interface AppHostProps {
19
- app: App | Key.Origin
19
+ app: Key.Origin
20
20
  /** Override the installed Domain entry View. */
21
21
  view?: ResolvedView
22
22
  capabilities?: HostCapabilities
@@ -31,7 +31,7 @@ export const AppHost = forwardRef<ViewHostHandle, AppHostProps>(
31
31
  const open = useOpenApp()
32
32
  const [opened, setOpened] = useState<ResolvedView | null | undefined>(undefined)
33
33
  const [error, setError] = useState<unknown>(undefined)
34
- const origin = typeof props.app === 'string' ? props.app : props.app.origin
34
+ const origin = props.app
35
35
  const openRef = useRef(open)
36
36
  openRef.current = open
37
37
 
@@ -1,12 +1,12 @@
1
1
  import type { Key } from '@astrale-os/sdk/schema'
2
- import type { App, ResolvedView } from '@astrale-os/shell'
2
+ import type { ResolvedView } from '@astrale-os/shell'
3
3
 
4
4
  import type { Action } from '../../graph/action.js'
5
5
 
6
6
  import { useAction } from '../../graph/action.js'
7
7
  import { useShell } from '../../session/shell.hook.js'
8
8
  /** Resolve one installed Domain's ordinary entry View. */
9
- export function useOpenApp(): Action<[App | Key.Origin], ResolvedView | null> {
9
+ export function useOpenApp(): Action<[Key.Origin], ResolvedView | null> {
10
10
  const shell = useShell()
11
- return useAction((app: App | Key.Origin) => shell.apps.open(app))
11
+ return useAction((origin: Key.Origin) => shell.views.entrypoint(origin))
12
12
  }
@@ -1,20 +1,8 @@
1
- // application — API §12.2. The OS-plane hooks over the shell package's
2
- // `shell.user/spaces/apps` namespaces + the window module: useUser, useSpaces,
3
- // useCurrentSpace, useSpace, useApps, useOpenApp,
4
- // AppHost, AppLogo.
5
1
  export { useUser } from './user/user.hook.js'
6
- export { useSpaces } from './spaces/spaces.hook.js'
7
- export type { SpacesResult } from './spaces/spaces.hook.js'
8
- export { useCurrentSpace } from './spaces/current-space.hook.js'
9
- export type { CurrentSpaceResult } from './spaces/current-space.hook.js'
10
- export { useSpace } from './spaces/space.hook.js'
11
- export type { SpaceResult } from './spaces/space.hook.js'
12
- export { useApps } from './apps/apps.hook.js'
13
- export type { AppsResult } from './apps/apps.hook.js'
14
2
  export { useOpenApp } from './apps/open-app.hook.js'
15
3
  export { AppHost } from './apps/app-host.js'
16
4
  export type { AppHostFallbackState, AppHostProps } from './apps/app-host.js'
17
5
  export { AppLogo } from './apps/app-logo.js'
18
6
  export type { AppLogoProps } from './apps/app-logo.js'
19
7
  // The OS node/session types (from the shell package, single import surface).
20
- export type { App, NodeTarget, ResolvedView, SpaceNode, UserNode } from '@astrale-os/shell'
8
+ export type { NodeTarget, ResolvedView, UserNode } from '@astrale-os/shell'
@@ -1,6 +1,6 @@
1
1
  /**
2
- * useUser (API §12.2) — self as the shell-domain `User` (typed
3
- * firstName/lastName/email). The provider PRE-RESOLVES it at boot (`primeUser`),
2
+ * useUser — self as a Shell.User descendant, preserving canonical graph property
3
+ * keys. The provider PRE-RESOLVES it at boot (`primeUser`),
4
4
  * so `useUser` is synchronous below the provider (API §2). OS-plane absent → throws
5
5
  * descriptively to the nearest boundary; a never-primed shell suspends on first use
6
6
  * (§1.4). FLAG: not yet re-rendered on rename — becomes live via a memory shape.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The OS-plane user store (API §2/§12.2). Provider-boot priming (`primeUser`) seeds
3
3
  * the per-Shell user cache so `useUser` is synchronous, and tracks OS-plane
4
- * availability: a missing/rejecting `shell.user.me` marks the plane down (the other
5
- * OS hooks then read idle) without failing boot. A never-primed shell falls back to
4
+ * availability: a missing/rejecting `shell.user.me` is retained in the User cache
5
+ * without failing boot. A never-primed shell falls back to
6
6
  * a lazy cold read that SUSPENDS on first use (§1.4).
7
7
  */
8
8
  import type { Shell, UserNode } from '@astrale-os/shell'
@@ -16,14 +16,6 @@ export type UserEntry =
16
16
 
17
17
  export const userCache = new WeakMap<Shell, UserEntry>()
18
18
 
19
- /** Shells whose OS plane could not be resolved at boot — OS hooks read idle. */
20
- const osPlaneDown = new WeakSet<Shell>()
21
-
22
- /** Is the OS plane (shell.user/spaces/apps) usable for this shell? */
23
- export function isOsPlaneAvailable(shell: Shell): boolean {
24
- return !osPlaneDown.has(shell)
25
- }
26
-
27
19
  interface UserSurface {
28
20
  user?: { me?: () => Promise<UserNode> }
29
21
  }
@@ -31,7 +23,7 @@ interface UserSurface {
31
23
  function osPlaneError(cause: unknown): Error {
32
24
  const detail = cause instanceof Error ? cause.message : String(cause)
33
25
  return new Error(
34
- `The OS plane is unavailable (useUser/useSpaces/useApps/useDomains): ` +
26
+ `The Shell User is unavailable (useUser): ` +
35
27
  `shell.user.me() could not resolve ` +
36
28
  `— the workspace/shell domain may not be installed for this identity. (${detail})`,
37
29
  )
@@ -45,7 +37,6 @@ function osPlaneError(cause: unknown): Error {
45
37
  export async function primeUser(shell: Shell): Promise<void> {
46
38
  const me = (shell as unknown as UserSurface).user?.me
47
39
  if (typeof me !== 'function') {
48
- osPlaneDown.add(shell)
49
40
  userCache.set(shell, {
50
41
  status: 'unavailable',
51
42
  error: osPlaneError('shell.user.me is not a function'),
@@ -54,10 +45,8 @@ export async function primeUser(shell: Shell): Promise<void> {
54
45
  }
55
46
  try {
56
47
  const value = await me.call((shell as unknown as UserSurface).user)
57
- osPlaneDown.delete(shell)
58
48
  userCache.set(shell, { status: 'resolved', value })
59
49
  } catch (error) {
60
- osPlaneDown.add(shell)
61
50
  userCache.set(shell, { status: 'unavailable', error: osPlaneError(error) })
62
51
  }
63
52
  }
package/src/index.ts CHANGED
@@ -175,25 +175,8 @@ export {
175
175
  export type { AstraleProps, ViewErrorBoundaryProps } from './components/index.js'
176
176
 
177
177
  // Shell application conveniences.
178
- export {
179
- AppHost,
180
- AppLogo,
181
- useApps,
182
- useCurrentSpace,
183
- useOpenApp,
184
- useSpace,
185
- useSpaces,
186
- useUser,
187
- } from './application/index.js'
188
- export type {
189
- AppHostFallbackState,
190
- AppHostProps,
191
- AppLogoProps,
192
- AppsResult,
193
- CurrentSpaceResult,
194
- SpaceResult,
195
- SpacesResult,
196
- } from './application/index.js'
178
+ export { AppHost, AppLogo, useOpenApp, useUser } from './application/index.js'
179
+ export type { AppHostFallbackState, AppHostProps, AppLogoProps } from './application/index.js'
197
180
 
198
181
  // Canonical identity carriers used by View props and rich hooks.
199
182
  export { NodeId } from '@astrale-os/sdk/graph/node'
@@ -205,7 +188,6 @@ export type { DomainBinding } from '@astrale-os/shell'
205
188
 
206
189
  // Shell contracts retained at the package root for custom full-app composition.
207
190
  export type {
208
- App,
209
191
  CloseResult,
210
192
  CredentialSnapshot,
211
193
  HostCapabilities,
@@ -215,7 +197,6 @@ export type {
215
197
  NodeTarget,
216
198
  Shell,
217
199
  ShellConfig,
218
- SpaceNode,
219
200
  UserNode,
220
201
  ViewLocation,
221
202
  ViewPresentation,
@@ -5,7 +5,7 @@ import type { Domain } from '@astrale-os/sdk/schema'
5
5
  import type {
6
6
  CallableInputOf,
7
7
  CallableResultOf,
8
- MethodInheritance,
8
+ ClassKey,
9
9
  ResolvedFunction,
10
10
  ResolvedMethod,
11
11
  } from '@astrale-os/sdk/schema'
@@ -22,14 +22,14 @@ import { useAction as useAsyncAction } from '../graph/action.js'
22
22
  import { useGraphStore } from '../graph/store.js'
23
23
  import { bindingFor } from './domain.hook.js'
24
24
 
25
- type ExecutableInheritance = Exclude<MethodInheritance, 'abstract'>
26
- type InstanceMethod = ResolvedMethod<unknown, unknown, false, ExecutableInheritance>
27
- type StaticMethod = ResolvedMethod<unknown, unknown, true, ExecutableInheritance>
25
+ type InstanceMethod = ResolvedMethod<unknown, unknown, false, false>
26
+ type StaticMethod = ResolvedMethod<unknown, unknown, true, false>
28
27
  type Callable = ResolvedFunction | InstanceMethod | StaticMethod
29
28
 
30
- /** Stable identity of an instance Method receiver; the Kernel resolves and authorizes its Class. */
29
+ /** Receiver identity with optional observed Class for early rejection; the Kernel checks live Class. */
31
30
  export interface ActionReceiver {
32
31
  readonly id: NodeId
32
+ readonly class?: ClassKey
33
33
  }
34
34
 
35
35
  const QUERY_REFRESH = Symbol('shell-react.query-refresh')
@@ -114,7 +114,7 @@ export function useAction<C extends Callable>(
114
114
  reference(
115
115
  bindingRef.current.domain,
116
116
  current,
117
- )(receiverPath(args[0] as ActionReceiver)),
117
+ )(receiverPath(args[0] as ActionReceiver, current)),
118
118
  args[1] as never,
119
119
  request,
120
120
  )
@@ -165,7 +165,11 @@ function isInstanceMethod(callable: Callable): callable is InstanceMethod {
165
165
  return 'owner' in callable && callable.static === false
166
166
  }
167
167
 
168
- function receiverPath(receiver: ActionReceiver): Path {
168
+ function receiverPath(receiver: ActionReceiver, method: InstanceMethod): Path {
169
+ const observedClass = receiver.class
170
+ if (observedClass !== undefined && observedClass !== method.owner.key) {
171
+ throw new TypeError(`Method ${method.key} requires exact receiver Class ${method.owner.key}.`)
172
+ }
169
173
  return Path.id(receiver.id)
170
174
  }
171
175
 
@@ -1,7 +0,0 @@
1
- import type { App } from '@astrale-os/shell';
2
- import type { ReadState } from '../../graph/memory/read/envelope.js';
3
- export interface AppsResult extends ReadState {
4
- readonly apps: readonly App[];
5
- }
6
- /** Read the installed Domain applications available to the current Shell session. */
7
- export declare function useApps(): AppsResult;
@@ -1,19 +0,0 @@
1
- import { useMemo } from 'react';
2
- import { useAsyncResource } from '../../graph/async.js';
3
- import { useShell } from '../../session/shell.hook.js';
4
- import { isOsPlaneAvailable } from '../user/user.store.js';
5
- const NO_APPS = [];
6
- /** Read the installed Domain applications available to the current Shell session. */
7
- export function useApps() {
8
- const shell = useShell();
9
- const available = isOsPlaneAvailable(shell);
10
- const result = useAsyncResource(available ? () => shell.apps.list() : null, NO_APPS, [shell, available]);
11
- return useMemo(() => ({
12
- apps: result.data,
13
- pending: result.pending,
14
- error: result.error,
15
- live: false,
16
- seq: 0,
17
- refetch: result.refetch,
18
- }), [result.data, result.pending, result.error, result.refetch]);
19
- }
@@ -1,6 +0,0 @@
1
- import type { SpaceNode } from '@astrale-os/shell';
2
- import type { ReadState } from '../../graph/memory/read/envelope.js';
3
- export interface CurrentSpaceResult extends ReadState {
4
- readonly space: SpaceNode | null;
5
- }
6
- export declare function useCurrentSpace(): CurrentSpaceResult;
@@ -1,27 +0,0 @@
1
- /**
2
- * useCurrentSpace (API §12.2) — the selected space, projected from the shared
3
- * per-Shell current-space store (so a switch anywhere re-themes it). One-shot
4
- * today (§12 flag); OS-plane-unavailable reads idle.
5
- */
6
- import { useEffect, useMemo, useSyncExternalStore } from 'react';
7
- import { useShell } from '../../session/shell.hook.js';
8
- import { isOsPlaneAvailable } from '../user/user.store.js';
9
- import { currentSpaceStore, idleSnapshot, noopSubscribe } from './current-space.store.js';
10
- export function useCurrentSpace() {
11
- const shell = useShell();
12
- const available = isOsPlaneAvailable(shell);
13
- const store = currentSpaceStore(shell);
14
- useEffect(() => {
15
- if (available)
16
- store.ensure();
17
- }, [store, available]);
18
- const snap = useSyncExternalStore(available ? store.subscribe : noopSubscribe, available ? store.getSnapshot : idleSnapshot, available ? store.getSnapshot : idleSnapshot);
19
- return useMemo(() => ({
20
- space: snap.space,
21
- pending: snap.pending,
22
- error: snap.error,
23
- live: false,
24
- seq: 0,
25
- refetch: store.refresh,
26
- }), [snap, store]);
27
- }
@@ -1,31 +0,0 @@
1
- /**
2
- * The shared current-space store (API §12.2). ONE reactive resolver per Shell, so
3
- * useCurrentSpace / useApps / useSpaces.selected all observe the SAME selected
4
- * space: a `switch` in any window re-themes every consumer in this window at once,
5
- * with a manual optimistic override applied ahead of the round-trip (§1.6). This
6
- * closes the same-window cross-hook divergence; cross-WINDOW liveness still waits
7
- * on the memory-shape move (§12 flag).
8
- */
9
- import type { Shell, SpaceNode } from '@astrale-os/shell';
10
- export interface CurrentSpaceSnapshot {
11
- readonly space: SpaceNode | null;
12
- readonly pending: boolean;
13
- readonly error: unknown;
14
- }
15
- export declare const IDLE_SNAPSHOT: CurrentSpaceSnapshot;
16
- export declare const idleSnapshot: () => CurrentSpaceSnapshot;
17
- export declare const noopSubscribe: () => (() => void);
18
- export interface CurrentSpaceStore {
19
- subscribe(cb: () => void): () => void;
20
- getSnapshot(): CurrentSpaceSnapshot;
21
- /** Kick a first load if the confirmed value was never fetched. */
22
- ensure(): void;
23
- /** Refetch the confirmed selected space; resolves when it lands. */
24
- refresh(): Promise<void>;
25
- /** Apply an optimistic override (a switch in flight) — every consumer re-renders. */
26
- setOptimistic(space: SpaceNode | null): void;
27
- /** Drop the optimistic override, revealing confirmed truth again. */
28
- clearOptimistic(): void;
29
- }
30
- /** The one current-space store for a Shell (created on first touch). */
31
- export declare function currentSpaceStore(shell: Shell): CurrentSpaceStore;
@@ -1,79 +0,0 @@
1
- // Stable idles for when the OS plane is unavailable (§12.2) — a fixed snapshot
2
- // and a no-op subscription keep `useSyncExternalStore` tear-free without touching
3
- // (and re-triggering a fetch on) the real current-space store.
4
- export const IDLE_SNAPSHOT = { space: null, pending: false, error: undefined };
5
- export const idleSnapshot = () => IDLE_SNAPSHOT;
6
- export const noopSubscribe = () => () => { };
7
- function createStore(load) {
8
- let confirmed = null;
9
- let loaded = false;
10
- let pending = false;
11
- let error = undefined;
12
- let override = null;
13
- let seq = 0;
14
- let snap = { space: null, pending: false, error: undefined };
15
- const listeners = new Set();
16
- /** Recompute the projection (override wins) and notify only on a real change. */
17
- function rebuild() {
18
- const space = override !== null ? override.space : confirmed;
19
- if (space === snap.space && pending === snap.pending && error === snap.error)
20
- return;
21
- snap = { space, pending, error };
22
- for (const l of listeners)
23
- l();
24
- }
25
- function fetch() {
26
- const my = ++seq;
27
- pending = true;
28
- rebuild();
29
- return load().then((space) => {
30
- if (my !== seq)
31
- return;
32
- confirmed = space;
33
- loaded = true;
34
- pending = false;
35
- error = undefined;
36
- rebuild();
37
- }, (e) => {
38
- if (my !== seq)
39
- return;
40
- pending = false;
41
- error = e;
42
- rebuild();
43
- });
44
- }
45
- return {
46
- subscribe(cb) {
47
- listeners.add(cb);
48
- if (!loaded && !pending)
49
- void fetch();
50
- return () => listeners.delete(cb);
51
- },
52
- getSnapshot: () => snap,
53
- ensure() {
54
- if (!loaded && !pending)
55
- void fetch();
56
- },
57
- refresh: () => fetch(),
58
- setOptimistic(space) {
59
- override = { space };
60
- rebuild();
61
- },
62
- clearOptimistic() {
63
- if (override === null)
64
- return;
65
- override = null;
66
- rebuild();
67
- },
68
- };
69
- }
70
- const stores = new WeakMap();
71
- /** The one current-space store for a Shell (created on first touch). */
72
- export function currentSpaceStore(shell) {
73
- let s = stores.get(shell);
74
- if (s === undefined) {
75
- s = createStore(() => shell.user.currentSpace());
76
- stores.set(shell, s);
77
- }
78
- return s;
79
- }
@@ -1,4 +0,0 @@
1
- import type { NodeTarget } from '@astrale-os/shell';
2
- import type { CurrentSpaceResult } from './current-space.hook.js';
3
- export type SpaceResult = CurrentSpaceResult;
4
- export declare function useSpace(ref: NodeTarget | null | undefined): SpaceResult;
@@ -1,28 +0,0 @@
1
- import { Path } from '@astrale-os/sdk/graph/path';
2
- /**
3
- * useSpace(ref) (API §12.2) — a single space by ref; `null`/`undefined` (or an
4
- * unavailable OS plane) is idle (§1.3). One-shot today (§12 flag).
5
- */
6
- import { useMemo } from 'react';
7
- import { useAsyncResource } from '../../graph/async.js';
8
- import { isIdleInput } from '../../graph/memory/read/idle.js';
9
- import { useShell } from '../../session/shell.hook.js';
10
- import { isOsPlaneAvailable } from '../user/user.store.js';
11
- export function useSpace(ref) {
12
- const shell = useShell();
13
- const idle = isIdleInput(ref) || !isOsPlaneAvailable(shell);
14
- // The inline guard (not the stored `idle`) narrows `ref` to non-null below.
15
- const key = idle ? null : targetKey(ref);
16
- const res = useAsyncResource(idle ? null : () => shell.spaces.get(ref), null, [shell, key]);
17
- return useMemo(() => ({
18
- space: idle ? null : res.data,
19
- pending: idle ? false : res.pending,
20
- error: idle ? undefined : res.error,
21
- live: false,
22
- seq: 0,
23
- refetch: res.refetch,
24
- }), [idle, res]);
25
- }
26
- function targetKey(input) {
27
- return typeof input === 'string' ? `text:${input}` : `path:${Path.from(input).raw}`;
28
- }
@@ -1,12 +0,0 @@
1
- import type { NodeTarget, SpaceNode } from '@astrale-os/shell';
2
- import type { Action } from '../../graph/action.js';
3
- import type { ReadState } from '../../graph/memory/read/envelope.js';
4
- export interface SpacesResult extends ReadState {
5
- /** Spaces-dir children, live (one-shot today), the caller's readable set. */
6
- readonly spaces: readonly SpaceNode[];
7
- /** The `selected_space` target — optimistically ahead during a `switch`. */
8
- readonly selected: SpaceNode | null;
9
- /** `user::switchSpace` with a manual optimistic overlay (§1.6). */
10
- readonly switch: Action<[NodeTarget], void>;
11
- }
12
- export declare function useSpaces(): SpacesResult;
@@ -1,89 +0,0 @@
1
- import { Path } from '@astrale-os/sdk/graph/path';
2
- /**
3
- * useSpaces (API §12.2) — the spaces list, the selected space, and a `switch`
4
- * Action that repoints the `selected_space` edge. Reads the SAME per-Shell
5
- * current-space store as useCurrentSpace/useApps, so a switch re-themes every
6
- * consumer in the window at once — optimistically ahead of the round-trip (§1.6),
7
- * commit reconciles, failure rolls back. Reads are one-shot today (§12 flag).
8
- */
9
- import { useEffect, useMemo, useSyncExternalStore } from 'react';
10
- import { useAction } from '../../graph/action.js';
11
- import { useAsyncResource } from '../../graph/async.js';
12
- import { useShell } from '../../session/shell.hook.js';
13
- import { isOsPlaneAvailable } from '../user/user.store.js';
14
- import { currentSpaceStore, idleSnapshot, noopSubscribe } from './current-space.store.js';
15
- const NO_SPACES = [];
16
- export function useSpaces() {
17
- const shell = useShell();
18
- const available = isOsPlaneAvailable(shell);
19
- const store = currentSpaceStore(shell);
20
- const spacesRes = useAsyncResource(available ? () => shell.spaces.list() : null, NO_SPACES, [shell, available]);
21
- useEffect(() => {
22
- if (available)
23
- store.ensure();
24
- }, [store, available]);
25
- const selectedSnap = useSyncExternalStore(available ? store.subscribe : noopSubscribe, available ? store.getSnapshot : idleSnapshot, available ? store.getSnapshot : idleSnapshot);
26
- const spaces = spacesRes.data;
27
- const switchAction = useAction(async (ref) => {
28
- // Optimistic override on the SHARED store, so useCurrentSpace / useApps ahead
29
- // of the round-trip too (§12.2). Only overlay a known target (a path ref that
30
- // matches nothing in the list simply skips the preview).
31
- const target = findSpace(spaces, ref);
32
- if (target !== null)
33
- store.setOptimistic(target);
34
- try {
35
- await shell.user.switchSpace(ref);
36
- await store.refresh(); // reconcile confirmed truth BEFORE dropping the preview
37
- store.clearOptimistic();
38
- }
39
- catch (error) {
40
- store.clearOptimistic(); // rollback: reveal the confirmed (previous) space
41
- throw error;
42
- }
43
- });
44
- return useMemo(() => ({
45
- spaces,
46
- selected: selectedSnap.space,
47
- switch: switchAction,
48
- pending: spacesRes.pending || selectedSnap.pending,
49
- error: spacesRes.error ?? selectedSnap.error,
50
- live: false,
51
- seq: 0,
52
- refetch: async () => {
53
- await Promise.all([spacesRes.refetch(), store.refresh()]);
54
- },
55
- }), [
56
- spaces,
57
- selectedSnap,
58
- switchAction,
59
- spacesRes.pending,
60
- spacesRes.error,
61
- spacesRes.refetch,
62
- store,
63
- ]);
64
- }
65
- /** The space in `spaces` denoted by `ref`, or `null` (path refs don't match by id). */
66
- function findSpace(spaces, ref) {
67
- const id = refIdOf(ref);
68
- if (id === null)
69
- return null;
70
- return spaces.find((s) => s.id === id) ?? null;
71
- }
72
- /** A bound-node-ish subject carries a graph id. */
73
- function hasId(x) {
74
- return typeof x === 'object' && x !== null && 'id' in x;
75
- }
76
- /** The graph id a subject denotes, or `null` when it is a path (no id to match). */
77
- function refIdOf(x) {
78
- if (hasId(x))
79
- return x.id;
80
- if (typeof x === 'string' && !x.startsWith('@') && !x.startsWith('/'))
81
- return x;
82
- try {
83
- const path = Path.from(x);
84
- return path.ast.anchor.kind === 'id' && path.ast.steps.length === 0 ? path.ast.anchor.id : null;
85
- }
86
- catch {
87
- return null;
88
- }
89
- }
@@ -1,38 +0,0 @@
1
- import type { App } from '@astrale-os/shell'
2
-
3
- import { useMemo } from 'react'
4
-
5
- import type { ReadState } from '../../graph/memory/read/envelope.js'
6
-
7
- import { useAsyncResource } from '../../graph/async.js'
8
- import { useShell } from '../../session/shell.hook.js'
9
- import { isOsPlaneAvailable } from '../user/user.store.js'
10
-
11
- const NO_APPS: readonly App[] = []
12
-
13
- export interface AppsResult extends ReadState {
14
- readonly apps: readonly App[]
15
- }
16
-
17
- /** Read the installed Domain applications available to the current Shell session. */
18
- export function useApps(): AppsResult {
19
- const shell = useShell()
20
- const available = isOsPlaneAvailable(shell)
21
- const result = useAsyncResource<readonly App[]>(
22
- available ? () => shell.apps.list() : null,
23
- NO_APPS,
24
- [shell, available],
25
- )
26
-
27
- return useMemo<AppsResult>(
28
- () => ({
29
- apps: result.data,
30
- pending: result.pending,
31
- error: result.error,
32
- live: false,
33
- seq: 0,
34
- refetch: result.refetch,
35
- }),
36
- [result.data, result.pending, result.error, result.refetch],
37
- )
38
- }
@@ -1,43 +0,0 @@
1
- import type { SpaceNode } from '@astrale-os/shell'
2
-
3
- /**
4
- * useCurrentSpace (API §12.2) — the selected space, projected from the shared
5
- * per-Shell current-space store (so a switch anywhere re-themes it). One-shot
6
- * today (§12 flag); OS-plane-unavailable reads idle.
7
- */
8
- import { useEffect, useMemo, useSyncExternalStore } from 'react'
9
-
10
- import type { ReadState } from '../../graph/memory/read/envelope.js'
11
-
12
- import { useShell } from '../../session/shell.hook.js'
13
- import { isOsPlaneAvailable } from '../user/user.store.js'
14
- import { currentSpaceStore, idleSnapshot, noopSubscribe } from './current-space.store.js'
15
-
16
- export interface CurrentSpaceResult extends ReadState {
17
- readonly space: SpaceNode | null
18
- }
19
-
20
- export function useCurrentSpace(): CurrentSpaceResult {
21
- const shell = useShell()
22
- const available = isOsPlaneAvailable(shell)
23
- const store = currentSpaceStore(shell)
24
- useEffect(() => {
25
- if (available) store.ensure()
26
- }, [store, available])
27
- const snap = useSyncExternalStore(
28
- available ? store.subscribe : noopSubscribe,
29
- available ? store.getSnapshot : idleSnapshot,
30
- available ? store.getSnapshot : idleSnapshot,
31
- )
32
- return useMemo(
33
- () => ({
34
- space: snap.space,
35
- pending: snap.pending,
36
- error: snap.error,
37
- live: false,
38
- seq: 0,
39
- refetch: store.refresh,
40
- }),
41
- [snap, store],
42
- )
43
- }
@@ -1,110 +0,0 @@
1
- /**
2
- * The shared current-space store (API §12.2). ONE reactive resolver per Shell, so
3
- * useCurrentSpace / useApps / useSpaces.selected all observe the SAME selected
4
- * space: a `switch` in any window re-themes every consumer in this window at once,
5
- * with a manual optimistic override applied ahead of the round-trip (§1.6). This
6
- * closes the same-window cross-hook divergence; cross-WINDOW liveness still waits
7
- * on the memory-shape move (§12 flag).
8
- */
9
- import type { Shell, SpaceNode } from '@astrale-os/shell'
10
-
11
- export interface CurrentSpaceSnapshot {
12
- readonly space: SpaceNode | null
13
- readonly pending: boolean
14
- readonly error: unknown
15
- }
16
-
17
- // Stable idles for when the OS plane is unavailable (§12.2) — a fixed snapshot
18
- // and a no-op subscription keep `useSyncExternalStore` tear-free without touching
19
- // (and re-triggering a fetch on) the real current-space store.
20
- export const IDLE_SNAPSHOT: CurrentSpaceSnapshot = { space: null, pending: false, error: undefined }
21
- export const idleSnapshot = (): CurrentSpaceSnapshot => IDLE_SNAPSHOT
22
- export const noopSubscribe = (): (() => void) => () => {}
23
-
24
- export interface CurrentSpaceStore {
25
- subscribe(cb: () => void): () => void
26
- getSnapshot(): CurrentSpaceSnapshot
27
- /** Kick a first load if the confirmed value was never fetched. */
28
- ensure(): void
29
- /** Refetch the confirmed selected space; resolves when it lands. */
30
- refresh(): Promise<void>
31
- /** Apply an optimistic override (a switch in flight) — every consumer re-renders. */
32
- setOptimistic(space: SpaceNode | null): void
33
- /** Drop the optimistic override, revealing confirmed truth again. */
34
- clearOptimistic(): void
35
- }
36
-
37
- function createStore(load: () => Promise<SpaceNode | null>): CurrentSpaceStore {
38
- let confirmed: SpaceNode | null = null
39
- let loaded = false
40
- let pending = false
41
- let error: unknown = undefined
42
- let override: { space: SpaceNode | null } | null = null
43
- let seq = 0
44
- let snap: CurrentSpaceSnapshot = { space: null, pending: false, error: undefined }
45
- const listeners = new Set<() => void>()
46
-
47
- /** Recompute the projection (override wins) and notify only on a real change. */
48
- function rebuild(): void {
49
- const space = override !== null ? override.space : confirmed
50
- if (space === snap.space && pending === snap.pending && error === snap.error) return
51
- snap = { space, pending, error }
52
- for (const l of listeners) l()
53
- }
54
-
55
- function fetch(): Promise<void> {
56
- const my = ++seq
57
- pending = true
58
- rebuild()
59
- return load().then(
60
- (space) => {
61
- if (my !== seq) return
62
- confirmed = space
63
- loaded = true
64
- pending = false
65
- error = undefined
66
- rebuild()
67
- },
68
- (e: unknown) => {
69
- if (my !== seq) return
70
- pending = false
71
- error = e
72
- rebuild()
73
- },
74
- )
75
- }
76
-
77
- return {
78
- subscribe(cb) {
79
- listeners.add(cb)
80
- if (!loaded && !pending) void fetch()
81
- return () => listeners.delete(cb)
82
- },
83
- getSnapshot: () => snap,
84
- ensure() {
85
- if (!loaded && !pending) void fetch()
86
- },
87
- refresh: () => fetch(),
88
- setOptimistic(space) {
89
- override = { space }
90
- rebuild()
91
- },
92
- clearOptimistic() {
93
- if (override === null) return
94
- override = null
95
- rebuild()
96
- },
97
- }
98
- }
99
-
100
- const stores = new WeakMap<Shell, CurrentSpaceStore>()
101
-
102
- /** The one current-space store for a Shell (created on first touch). */
103
- export function currentSpaceStore(shell: Shell): CurrentSpaceStore {
104
- let s = stores.get(shell)
105
- if (s === undefined) {
106
- s = createStore(() => shell.user.currentSpace())
107
- stores.set(shell, s)
108
- }
109
- return s
110
- }
@@ -1,45 +0,0 @@
1
- import type { PathLike } from '@astrale-os/sdk/graph/path'
2
- import type { NodeTarget, SpaceNode } from '@astrale-os/shell'
3
-
4
- import { Path } from '@astrale-os/sdk/graph/path'
5
- /**
6
- * useSpace(ref) (API §12.2) — a single space by ref; `null`/`undefined` (or an
7
- * unavailable OS plane) is idle (§1.3). One-shot today (§12 flag).
8
- */
9
- import { useMemo } from 'react'
10
-
11
- import type { CurrentSpaceResult } from './current-space.hook.js'
12
-
13
- import { useAsyncResource } from '../../graph/async.js'
14
- import { isIdleInput } from '../../graph/memory/read/idle.js'
15
- import { useShell } from '../../session/shell.hook.js'
16
- import { isOsPlaneAvailable } from '../user/user.store.js'
17
-
18
- export type SpaceResult = CurrentSpaceResult
19
-
20
- export function useSpace(ref: NodeTarget | null | undefined): SpaceResult {
21
- const shell = useShell()
22
- const idle = isIdleInput(ref) || !isOsPlaneAvailable(shell)
23
- // The inline guard (not the stored `idle`) narrows `ref` to non-null below.
24
- const key = idle ? null : targetKey(ref as NodeTarget)
25
- const res = useAsyncResource<SpaceNode | null>(
26
- idle ? null : () => shell.spaces.get(ref as NodeTarget),
27
- null,
28
- [shell, key],
29
- )
30
- return useMemo(
31
- () => ({
32
- space: idle ? null : res.data,
33
- pending: idle ? false : res.pending,
34
- error: idle ? undefined : res.error,
35
- live: false,
36
- seq: 0,
37
- refetch: res.refetch,
38
- }),
39
- [idle, res],
40
- )
41
- }
42
-
43
- function targetKey(input: NodeTarget): string {
44
- return typeof input === 'string' ? `text:${input}` : `path:${Path.from(input as PathLike).raw}`
45
- }
@@ -1,118 +0,0 @@
1
- import type { PathLike } from '@astrale-os/sdk/graph/path'
2
- import type { NodeTarget, SpaceNode } from '@astrale-os/shell'
3
-
4
- import { Path } from '@astrale-os/sdk/graph/path'
5
- /**
6
- * useSpaces (API §12.2) — the spaces list, the selected space, and a `switch`
7
- * Action that repoints the `selected_space` edge. Reads the SAME per-Shell
8
- * current-space store as useCurrentSpace/useApps, so a switch re-themes every
9
- * consumer in the window at once — optimistically ahead of the round-trip (§1.6),
10
- * commit reconciles, failure rolls back. Reads are one-shot today (§12 flag).
11
- */
12
- import { useEffect, useMemo, useSyncExternalStore } from 'react'
13
-
14
- import type { Action } from '../../graph/action.js'
15
- import type { ReadState } from '../../graph/memory/read/envelope.js'
16
-
17
- import { useAction } from '../../graph/action.js'
18
- import { useAsyncResource } from '../../graph/async.js'
19
- import { useShell } from '../../session/shell.hook.js'
20
- import { isOsPlaneAvailable } from '../user/user.store.js'
21
- import { currentSpaceStore, idleSnapshot, noopSubscribe } from './current-space.store.js'
22
-
23
- const NO_SPACES: readonly SpaceNode[] = []
24
-
25
- export interface SpacesResult extends ReadState {
26
- /** Spaces-dir children, live (one-shot today), the caller's readable set. */
27
- readonly spaces: readonly SpaceNode[]
28
- /** The `selected_space` target — optimistically ahead during a `switch`. */
29
- readonly selected: SpaceNode | null
30
- /** `user::switchSpace` with a manual optimistic overlay (§1.6). */
31
- readonly switch: Action<[NodeTarget], void>
32
- }
33
-
34
- export function useSpaces(): SpacesResult {
35
- const shell = useShell()
36
- const available = isOsPlaneAvailable(shell)
37
- const store = currentSpaceStore(shell)
38
-
39
- const spacesRes = useAsyncResource<readonly SpaceNode[]>(
40
- available ? () => shell.spaces.list() : null,
41
- NO_SPACES,
42
- [shell, available],
43
- )
44
- useEffect(() => {
45
- if (available) store.ensure()
46
- }, [store, available])
47
- const selectedSnap = useSyncExternalStore(
48
- available ? store.subscribe : noopSubscribe,
49
- available ? store.getSnapshot : idleSnapshot,
50
- available ? store.getSnapshot : idleSnapshot,
51
- )
52
-
53
- const spaces = spacesRes.data
54
-
55
- const switchAction = useAction(async (ref: NodeTarget): Promise<void> => {
56
- // Optimistic override on the SHARED store, so useCurrentSpace / useApps ahead
57
- // of the round-trip too (§12.2). Only overlay a known target (a path ref that
58
- // matches nothing in the list simply skips the preview).
59
- const target = findSpace(spaces, ref)
60
- if (target !== null) store.setOptimistic(target)
61
- try {
62
- await shell.user.switchSpace(ref)
63
- await store.refresh() // reconcile confirmed truth BEFORE dropping the preview
64
- store.clearOptimistic()
65
- } catch (error) {
66
- store.clearOptimistic() // rollback: reveal the confirmed (previous) space
67
- throw error
68
- }
69
- })
70
-
71
- return useMemo<SpacesResult>(
72
- () => ({
73
- spaces,
74
- selected: selectedSnap.space,
75
- switch: switchAction,
76
- pending: spacesRes.pending || selectedSnap.pending,
77
- error: spacesRes.error ?? selectedSnap.error,
78
- live: false,
79
- seq: 0,
80
- refetch: async () => {
81
- await Promise.all([spacesRes.refetch(), store.refresh()])
82
- },
83
- }),
84
- [
85
- spaces,
86
- selectedSnap,
87
- switchAction,
88
- spacesRes.pending,
89
- spacesRes.error,
90
- spacesRes.refetch,
91
- store,
92
- ],
93
- )
94
- }
95
-
96
- /** The space in `spaces` denoted by `ref`, or `null` (path refs don't match by id). */
97
- function findSpace(spaces: readonly SpaceNode[], ref: NodeTarget): SpaceNode | null {
98
- const id = refIdOf(ref)
99
- if (id === null) return null
100
- return spaces.find((s) => s.id === id) ?? null
101
- }
102
-
103
- /** A bound-node-ish subject carries a graph id. */
104
- function hasId(x: unknown): x is { readonly id: string } {
105
- return typeof x === 'object' && x !== null && 'id' in x
106
- }
107
-
108
- /** The graph id a subject denotes, or `null` when it is a path (no id to match). */
109
- function refIdOf(x: NodeTarget | { readonly id: string }): string | null {
110
- if (hasId(x)) return x.id
111
- if (typeof x === 'string' && !x.startsWith('@') && !x.startsWith('/')) return x
112
- try {
113
- const path = Path.from(x as PathLike)
114
- return path.ast.anchor.kind === 'id' && path.ast.steps.length === 0 ? path.ast.anchor.id : null
115
- } catch {
116
- return null
117
- }
118
- }