@stapel/workspaces-react 0.10.0 → 0.11.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.
@@ -661,6 +661,50 @@ export interface paths {
661
661
  patch?: never;
662
662
  trace?: never;
663
663
  };
664
+ "/workspaces/api/v1/me/preferred-workspace": {
665
+ parameters: {
666
+ query?: never;
667
+ header?: never;
668
+ path?: never;
669
+ cookie?: never;
670
+ };
671
+ get?: never;
672
+ /**
673
+ * @description ``PUT/DELETE me/preferred-workspace`` — the person states where home is.
674
+ *
675
+ * ``STAPEL_WORKSPACES["DEFAULT_WORKSPACE_ID"]`` already describes itself as
676
+ * "a DEFAULT, not a cage: a person still switches spaces, and their
677
+ * explicit choice wins over it" — and until this endpoint there was nowhere
678
+ * for that choice to be written down. Clients filled the hole by guessing,
679
+ * and the guess (``workspaces[0]`` off a recency-ordered list) is #239.
680
+ *
681
+ * The choice is stated, never inferred. ``last_accessed_at`` remains what
682
+ * it has always been: telemetry written as a side effect of a GET, used to
683
+ * sort the list and for nothing else.
684
+ *
685
+ * Deliberately user-scoped rather than workspace-scoped
686
+ * (``.../<workspace_id>/prefer``): there is exactly one answer per person,
687
+ * and a route shaped per workspace would invite a second one.
688
+ *
689
+ * **Permissions:** `IsAuthenticated`
690
+ */
691
+ put: operations["workspaces_api_v1_me_preferred_workspace_update"];
692
+ post?: never;
693
+ /**
694
+ * @description Clear the choice — back to the instance default / the client's chain.
695
+ *
696
+ * Answers 200 with an empty id rather than 204: the client's whole job
697
+ * here is to re-resolve, and handing it the new state saves it from
698
+ * guessing what "no content" left behind. Idempotent.
699
+ *
700
+ * **Permissions:** `IsAuthenticated`
701
+ */
702
+ delete: operations["workspaces_api_v1_me_preferred_workspace_destroy"];
703
+ options?: never;
704
+ head?: never;
705
+ patch?: never;
706
+ trace?: never;
707
+ };
664
708
  "/workspaces/api/v1/roles": {
665
709
  parameters: {
666
710
  query?: never;
@@ -1000,6 +1044,23 @@ export interface components {
1000
1044
  [key: string]: unknown;
1001
1045
  } | null;
1002
1046
  };
1047
+ /** @description Which workspace the person is choosing as home. */
1048
+ PreferredWorkspaceRequest: {
1049
+ /**
1050
+ * Format: uuid
1051
+ * @description UUID of a workspace the caller actively belongs to
1052
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1053
+ */
1054
+ workspace_id: string;
1055
+ };
1056
+ /** @description The caller's stated home workspace after the write. */
1057
+ PreferredWorkspaceResponse: {
1058
+ /**
1059
+ * @description The workspace now recorded as the caller's choice, or "" after a DELETE cleared it
1060
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1061
+ */
1062
+ preferred_workspace_id?: string;
1063
+ };
1003
1064
  /** @description Provision an org-created (synthetic) member (org-program spec §C1). */
1004
1065
  ProvisionMemberRequest: {
1005
1066
  /**
@@ -1132,6 +1193,11 @@ export interface components {
1132
1193
  * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1133
1194
  */
1134
1195
  default_workspace_id?: string;
1196
+ /**
1197
+ * @description The person's OWN stated choice of home workspace (PUT me/preferred-workspace), echoed back ONLY while the membership carrying it is active, else "". This is the "explicit choice" default_workspace_id documents itself as yielding to, so a client resolves preferred first and the instance default only after it. Never inferred from behaviour — where somebody last clicked is last_accessed_at, which is telemetry, and reading it as a choice is what produced #239
1198
+ * @example a8bba7ae-5d2e-43e5-9911-0553e7df50b3
1199
+ */
1200
+ preferred_workspace_id?: string;
1135
1201
  };
1136
1202
  /** @description Workspace details. */
1137
1203
  WorkspaceResponse: {
@@ -1767,6 +1833,50 @@ export interface operations {
1767
1833
  };
1768
1834
  };
1769
1835
  };
1836
+ workspaces_api_v1_me_preferred_workspace_update: {
1837
+ parameters: {
1838
+ query?: never;
1839
+ header?: never;
1840
+ path?: never;
1841
+ cookie?: never;
1842
+ };
1843
+ requestBody: {
1844
+ content: {
1845
+ "application/json": components["schemas"]["PreferredWorkspaceRequest"];
1846
+ "application/x-www-form-urlencoded": components["schemas"]["PreferredWorkspaceRequest"];
1847
+ "multipart/form-data": components["schemas"]["PreferredWorkspaceRequest"];
1848
+ };
1849
+ };
1850
+ responses: {
1851
+ 200: {
1852
+ headers: {
1853
+ [name: string]: unknown;
1854
+ };
1855
+ content: {
1856
+ "application/json": components["schemas"]["PreferredWorkspaceResponse"];
1857
+ };
1858
+ };
1859
+ };
1860
+ };
1861
+ workspaces_api_v1_me_preferred_workspace_destroy: {
1862
+ parameters: {
1863
+ query?: never;
1864
+ header?: never;
1865
+ path?: never;
1866
+ cookie?: never;
1867
+ };
1868
+ requestBody?: never;
1869
+ responses: {
1870
+ 200: {
1871
+ headers: {
1872
+ [name: string]: unknown;
1873
+ };
1874
+ content: {
1875
+ "application/json": components["schemas"]["PreferredWorkspaceResponse"];
1876
+ };
1877
+ };
1878
+ };
1879
+ };
1770
1880
  workspaces_api_v1_roles_retrieve: {
1771
1881
  parameters: {
1772
1882
  query?: never;
package/src/api/types.ts CHANGED
@@ -24,6 +24,15 @@ export type Operations = operations;
24
24
  export type Workspace = Schemas["WorkspaceResponse"];
25
25
  /** GET / 200 body — the caller's workspaces (memberships). */
26
26
  export type WorkspaceList = Schemas["WorkspaceListResponse"];
27
+ /** PUT /me/preferred-workspace request body — the workspace the person is
28
+ * choosing as home. Must be one they actively belong to; anything else
29
+ * (unknown, not a member, invitation still pending, membership suspended)
30
+ * answers ONE identical `error.404.workspace_not_found`, so the endpoint
31
+ * cannot be used to probe which workspace ids are real. */
32
+ export type PreferredWorkspace = Schemas["PreferredWorkspaceRequest"];
33
+ /** PUT/DELETE /me/preferred-workspace 200 body — the choice after the write
34
+ * (`""` after a DELETE cleared it). */
35
+ export type PreferredWorkspaceResult = Schemas["PreferredWorkspaceResponse"];
27
36
  /** POST / request body — create a workspace (slug auto-generated when omitted). */
28
37
  export type WorkspaceCreate = Schemas["WorkspaceCreateRequest"];
29
38
  /** PATCH /{id} request body — a partial name / slug / settings update. */
@@ -13,6 +13,8 @@ import type {
13
13
  MemberPasswordResetResult,
14
14
  MemberRoleUpdate,
15
15
  MembersParams,
16
+ PreferredWorkspace,
17
+ PreferredWorkspaceResult,
16
18
  InvitationAccept,
17
19
  InvitationClaim,
18
20
  InvitationPreview,
@@ -76,6 +78,15 @@ export interface WorkspacesApi {
76
78
  /** Soft-delete a workspace (owner only). */
77
79
  deleteWorkspace(workspaceId: string): Promise<void>;
78
80
 
81
+ /**
82
+ * Record the caller's home workspace — their EXPLICIT choice, which the
83
+ * instance's `default_workspace_id` documents itself as yielding to.
84
+ * Answers 404 for anything they cannot actively open.
85
+ */
86
+ setPreferredWorkspace(body: PreferredWorkspace): Promise<PreferredWorkspaceResult>;
87
+ /** Clear the caller's home workspace — back to the resolution chain. Idempotent. */
88
+ clearPreferredWorkspace(): Promise<PreferredWorkspaceResult>;
89
+
79
90
  /** A workspace's members (viewer+), an anchor-paginated page. */
80
91
  listMembers(
81
92
  workspaceId: string,
@@ -238,6 +249,16 @@ export function createWorkspacesApi(client: StapelClient): WorkspacesApi {
238
249
  deleteWorkspace: (workspaceId) =>
239
250
  client.delete(`/${encodeURIComponent(workspaceId)}`, mutating()),
240
251
 
252
+ setPreferredWorkspace: (body) =>
253
+ client.put(
254
+ "/me/preferred-workspace",
255
+ body satisfies PreferredWorkspace,
256
+ mutating()
257
+ ),
258
+
259
+ clearPreferredWorkspace: () =>
260
+ client.delete("/me/preferred-workspace", mutating()),
261
+
241
262
  listMembers: (workspaceId, params) => {
242
263
  const query: Record<string, string | number> = {};
243
264
  if (params?.anchor !== undefined) query.anchor = params.anchor;
package/src/index.ts CHANGED
@@ -52,6 +52,18 @@ export {
52
52
  } from "./model/context.js";
53
53
  export { workspacesQueryKeys } from "./model/queryKeys.js";
54
54
 
55
+ // ── model (active-workspace selection: URL > localStorage > backend) ─────────
56
+ export {
57
+ WorkspaceSelectionProvider,
58
+ useWorkspaceSelection,
59
+ } from "./model/selection.js";
60
+ export type {
61
+ WorkspaceSelection,
62
+ WorkspaceSelectionProviderProps,
63
+ WorkspaceSelectionSource,
64
+ WorkspaceSelectionUrlBinding,
65
+ } from "./model/selection.js";
66
+
55
67
  // ── model (read hooks) ───────────────────────────────────────────────────────
56
68
  export {
57
69
  useWorkspaces,
@@ -98,6 +110,8 @@ export {
98
110
  useResendInvitation,
99
111
  useResetMemberPassword,
100
112
  useUpdateSecuritySettings,
113
+ useSetPreferredWorkspace,
114
+ useClearPreferredWorkspace,
101
115
  } from "./model/mutations.js";
102
116
  export type {
103
117
  MemberRoleChange,
@@ -127,6 +141,8 @@ export type {
127
141
  InvitationClaim,
128
142
  MemberPasswordReset,
129
143
  MemberPasswordResetResult,
144
+ PreferredWorkspace,
145
+ PreferredWorkspaceResult,
130
146
  DisplayNameUpdate,
131
147
  DisplayNameResult,
132
148
  ProvisionedUserPolicy,
@@ -14,6 +14,8 @@ import type {
14
14
  MemberPasswordResetResult,
15
15
  InvitationAccept,
16
16
  InvitationClaim,
17
+ PreferredWorkspace,
18
+ PreferredWorkspaceResult,
17
19
  Workspace,
18
20
  WorkspaceCreate,
19
21
  WorkspaceSecuritySettings,
@@ -565,3 +567,60 @@ export function useDeclineInvitation(): UseMutationResult<
565
567
  };
566
568
  return useMutation(options);
567
569
  }
570
+
571
+ /**
572
+ * Record the caller's home workspace (PUT /me/preferred-workspace).
573
+ *
574
+ * Invalidates the workspace list, because the list response is where the
575
+ * choice is READ back from (`preferred_workspace_id`) — one round trip, and
576
+ * no window in which the list has arrived but the answer has not.
577
+ *
578
+ * Callers should treat this as fire-and-forget: {@link useWorkspaceSelection}
579
+ * switches the tab locally and never blocks on the round trip. A switch that
580
+ * hangs on a flaky network is worse than a preference that lags one click.
581
+ */
582
+ export function useSetPreferredWorkspace(): UseMutationResult<
583
+ PreferredWorkspaceResult,
584
+ StapelApiError,
585
+ PreferredWorkspace
586
+ > {
587
+ const api = useWorkspacesApi();
588
+ const queryClient = useQueryClient();
589
+ const options: UseMutationOptions<
590
+ PreferredWorkspaceResult,
591
+ StapelApiError,
592
+ PreferredWorkspace
593
+ > = {
594
+ mutationFn: (body) => api.setPreferredWorkspace(body),
595
+ onSuccess: () => {
596
+ void queryClient.invalidateQueries({
597
+ queryKey: workspacesQueryKeys.list(),
598
+ });
599
+ },
600
+ };
601
+ return useMutation(options);
602
+ }
603
+
604
+ /** Clear the caller's home workspace (DELETE /me/preferred-workspace) — back
605
+ * to the instance default and the rest of the resolution chain. Idempotent. */
606
+ export function useClearPreferredWorkspace(): UseMutationResult<
607
+ PreferredWorkspaceResult,
608
+ StapelApiError,
609
+ void
610
+ > {
611
+ const api = useWorkspacesApi();
612
+ const queryClient = useQueryClient();
613
+ const options: UseMutationOptions<
614
+ PreferredWorkspaceResult,
615
+ StapelApiError,
616
+ void
617
+ > = {
618
+ mutationFn: () => api.clearPreferredWorkspace(),
619
+ onSuccess: () => {
620
+ void queryClient.invalidateQueries({
621
+ queryKey: workspacesQueryKeys.list(),
622
+ });
623
+ },
624
+ };
625
+ return useMutation(options);
626
+ }
@@ -0,0 +1,382 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import type { Context, ReactElement, ReactNode } from "react";
11
+ import { createRepository } from "@stapel/core";
12
+ import type { Repository } from "@stapel/core";
13
+ import type { Workspace } from "../api/types.js";
14
+ import { useWorkspaces } from "./queries.js";
15
+ import { useSetPreferredWorkspace } from "./mutations.js";
16
+
17
+ /**
18
+ * Which workspace am I in — the resolution the whole fleet was inventing
19
+ * separately, in one place.
20
+ *
21
+ * There are three places an answer can come from, and the design IS the order
22
+ * between them:
23
+ *
24
+ * 1. **the URL** — highest. This is what makes several browser tabs each work
25
+ * in a different workspace, and it only works because it is highest: a
26
+ * tab's identity must never be stolen by another tab writing shared
27
+ * storage.
28
+ * 2. **local storage** — the returning-user default on this device.
29
+ * 3. **the backend preference** (`preferred_workspace_id`, stapel-workspaces
30
+ * 0.20) — follows the person across devices and browsers.
31
+ *
32
+ * then the instance's `default_workspace_id`, then the personal workspace,
33
+ * then, and only then, the first row.
34
+ *
35
+ * `workspaces[0]` was the rule every client invented, and it is #239: the
36
+ * owner's pending invitations sat in the org workspace while his screen showed
37
+ * his personal one, because the list is ordered by `-last_accessed_at` and the
38
+ * first row is therefore "wherever you happened to be last", not a choice. It
39
+ * survives here only as step 6 — reached by someone with no URL, no local
40
+ * history, no recorded choice, no instance default and no personal workspace —
41
+ * because for the single-workspace majority it is trivially right, and
42
+ * refusing to pick would punish them with a chooser screen listing one option.
43
+ */
44
+
45
+ /** Where {@link WorkspaceSelection.current} came from — the step of the chain
46
+ * that answered. Exposed because "why am I here" is otherwise unanswerable
47
+ * from the outside, and every previous version of this bug was invisible. */
48
+ export type WorkspaceSelectionSource =
49
+ | "url"
50
+ | "local"
51
+ | "preference"
52
+ | "instance-default"
53
+ | "personal"
54
+ | "positional";
55
+
56
+ /**
57
+ * How a host binds the URL layer. Both halves are optional: omitted, the
58
+ * library runs on the remaining layers and multi-tab independence is simply
59
+ * not available — it is not an error, it is a host that has not wired it.
60
+ *
61
+ * Controlled props rather than the library reading `window.location` itself,
62
+ * and rather than an injected `{read, write}` adapter, for one mechanical
63
+ * reason: SPA routers navigate through `history.pushState`, which emits no
64
+ * observable event. A library reading `location` could not RE-RENDER when the
65
+ * URL changed, and a `read()` adapter has the same defect in disguise (a
66
+ * correct one would need `subscribe`, re-implementing what the host router's
67
+ * own re-render already gives us for free). Writing `history` directly would
68
+ * also desynchronise the host router's location state.
69
+ */
70
+ export interface WorkspaceSelectionUrlBinding {
71
+ /**
72
+ * The workspace id the host read out of the URL — `?workspace=<uuid>` by
73
+ * convention. `null`/`undefined` means the URL names none.
74
+ *
75
+ * A query parameter, not a path segment, deliberately: a path parameter
76
+ * would force a `/w/:workspaceId/*` prefix into every host's route table,
77
+ * which is this pair dictating routing structure — the exact thing the seam
78
+ * exists to avoid.
79
+ */
80
+ readonly urlWorkspaceId?: string | null;
81
+ /**
82
+ * The URL should now name `workspaceId`. The host navigates; this pair
83
+ * never touches history itself.
84
+ *
85
+ * `reason: "switch"` is an explicit picker action and wants a history
86
+ * PUSH — back should return the person to the workspace they came from.
87
+ * `reason: "invalid-url"` is the correction after a URL named a workspace
88
+ * the person cannot open, and wants a REPLACE — a URL that does not work
89
+ * must not stay reachable with the back button.
90
+ */
91
+ readonly onUrlWorkspaceChange?: (
92
+ workspaceId: string,
93
+ meta: { reason: "switch" | "invalid-url"; history: "push" | "replace" }
94
+ ) => void;
95
+ }
96
+
97
+ /** What {@link useWorkspaceSelection} hands back. */
98
+ export interface WorkspaceSelection {
99
+ /** The caller's workspaces (empty before load, and for a guest). */
100
+ readonly workspaces: readonly Workspace[];
101
+ /**
102
+ * The resolved workspace — `null` while loading, and for a person who
103
+ * belongs to none.
104
+ *
105
+ * Downstream fetching must gate on THIS, never on a raw id: an id that has
106
+ * not been checked against the fetched list is exactly how a client ends up
107
+ * briefly rendering with a workspace the person cannot open.
108
+ */
109
+ readonly current: Workspace | null;
110
+ /** Which step of the chain answered, or `null` before one has. */
111
+ readonly source: WorkspaceSelectionSource | null;
112
+ /**
113
+ * The URL named a workspace that is not in the caller's list — it does not
114
+ * exist, or they are not a member, or their membership is suspended (the
115
+ * backend deliberately does not say which). Drive a visible notice off
116
+ * this. The person has already been landed somewhere sane.
117
+ */
118
+ readonly urlWorkspaceInvalid: boolean;
119
+ /** The workspace list is still loading. */
120
+ readonly loading: boolean;
121
+ /**
122
+ * An EXPLICIT choice by the person — the only entry point that persists.
123
+ * Writes the URL (push), local storage, and the backend preference.
124
+ * Returns immediately; the backend write is fire-and-forget.
125
+ */
126
+ switchTo: (workspaceId: string) => void;
127
+ /** Refetch the workspace list. */
128
+ refetch: () => Promise<void>;
129
+ }
130
+
131
+ const SelectionContext: Context<WorkspaceSelection | null> =
132
+ createContext<WorkspaceSelection | null>(null);
133
+
134
+ /** The repository namespace and key holding the local-storage layer. */
135
+ const REPO_NAMESPACE = "workspaces";
136
+ const REPO_KEY = "selected";
137
+
138
+ /**
139
+ * `scope: "user"` is deliberate on both counts it implies. It is encrypted at
140
+ * rest like the rest of the session's data, and — the part that matters — it
141
+ * is wiped at logout, so the next account signed in on a shared machine
142
+ * cannot inherit the previous account's workspace pointer.
143
+ */
144
+ function defaultRepository(): Repository<string> {
145
+ return createRepository<string>(REPO_NAMESPACE, { scope: "user" });
146
+ }
147
+
148
+ /** Props for {@link WorkspaceSelectionProvider}. */
149
+ export interface WorkspaceSelectionProviderProps
150
+ extends WorkspaceSelectionUrlBinding {
151
+ readonly children: ReactNode;
152
+ /**
153
+ * Override the local-storage layer. Injected for tests and for a host with
154
+ * its own persistence policy; the default is core's `createRepository`
155
+ * (`scope: "user"`). Pass `null` to run with NO local layer at all.
156
+ */
157
+ readonly repository?: Repository<string> | null;
158
+ }
159
+
160
+ /**
161
+ * Resolves and holds the active workspace. Wrap the part of the app that
162
+ * needs one; read it with {@link useWorkspaceSelection}.
163
+ *
164
+ * ```tsx
165
+ * // react-router 7 host — the pair never imports the router
166
+ * const [params, setParams] = useSearchParams();
167
+ * <WorkspaceSelectionProvider
168
+ * urlWorkspaceId={params.get("workspace")}
169
+ * onUrlWorkspaceChange={(id, { history }) =>
170
+ * setParams((prev) => {
171
+ * const next = new URLSearchParams(prev);
172
+ * next.set("workspace", id);
173
+ * return next;
174
+ * }, { replace: history === "replace" })}
175
+ * >
176
+ * <App />
177
+ * </WorkspaceSelectionProvider>
178
+ * ```
179
+ */
180
+ export function WorkspaceSelectionProvider(
181
+ props: WorkspaceSelectionProviderProps
182
+ ): ReactElement {
183
+ const { urlWorkspaceId, onUrlWorkspaceChange, children } = props;
184
+ const query = useWorkspaces();
185
+ const setPreferred = useSetPreferredWorkspace();
186
+
187
+ // Built once. `createRepository` registers a logout wipe hook, so calling it
188
+ // per render would register one per render.
189
+ const repoRef = useRef<Repository<string> | null | undefined>(undefined);
190
+ if (repoRef.current === undefined) {
191
+ repoRef.current =
192
+ props.repository === undefined ? defaultRepository() : props.repository;
193
+ }
194
+ const repo = repoRef.current;
195
+
196
+ const [localId, setLocalId] = useState<string | null>(null);
197
+ const [localRead, setLocalRead] = useState(!repo);
198
+ const [chosenId, setChosenId] = useState<string | null>(null);
199
+
200
+ // The local layer is read EXACTLY ONCE, at mount, and never subscribed to.
201
+ //
202
+ // That is the multi-tab requirement, stated as code. The obvious "improvement"
203
+ // is a `storage` event listener to keep tabs in sync — and it is precisely
204
+ // what breaks the feature: `storage` fires in the OTHER tabs on every write,
205
+ // so switching in tab A would drag tab B onto A's workspace, which is the
206
+ // behaviour the URL layer exists to prevent. There is no listener here, and
207
+ // there must never be one.
208
+ useEffect(() => {
209
+ if (!repo) return;
210
+ let cancelled = false;
211
+ void repo
212
+ .get(REPO_KEY)
213
+ .then((value) => {
214
+ if (cancelled) return;
215
+ if (typeof value === "string") setLocalId(value);
216
+ })
217
+ .catch(() => undefined)
218
+ .finally(() => {
219
+ if (!cancelled) setLocalRead(true);
220
+ });
221
+ return () => {
222
+ cancelled = true;
223
+ };
224
+ }, [repo]);
225
+
226
+ const workspaces = useMemo(
227
+ () => query.data?.workspaces ?? [],
228
+ [query.data]
229
+ );
230
+ const loading = query.isLoading || !localRead;
231
+
232
+ const memberOf = useCallback(
233
+ (id: string | null | undefined): Workspace | null =>
234
+ id ? (workspaces.find((w) => w.id === id) ?? null) : null,
235
+ [workspaces]
236
+ );
237
+
238
+ // The chain. Runs against the FETCHED list only — never against an
239
+ // unresolved one, or a raw stored id would be briefly authoritative and the
240
+ // app would fetch a workspace it has not verified the person can open.
241
+ const resolved = useMemo<{
242
+ workspace: Workspace | null;
243
+ source: WorkspaceSelectionSource | null;
244
+ }>(() => {
245
+ if (loading || workspaces.length === 0) {
246
+ return { workspace: null, source: null };
247
+ }
248
+ const explicit = memberOf(chosenId);
249
+ if (explicit) return { workspace: explicit, source: "url" };
250
+ const fromUrl = memberOf(urlWorkspaceId);
251
+ if (fromUrl) return { workspace: fromUrl, source: "url" };
252
+ const fromLocal = memberOf(localId);
253
+ if (fromLocal) return { workspace: fromLocal, source: "local" };
254
+ const fromPreference = memberOf(query.data?.preferred_workspace_id);
255
+ if (fromPreference)
256
+ return { workspace: fromPreference, source: "preference" };
257
+ const fromInstance = memberOf(query.data?.default_workspace_id);
258
+ if (fromInstance)
259
+ return { workspace: fromInstance, source: "instance-default" };
260
+ const personal = workspaces.find((w) => w.type === "personal");
261
+ if (personal) return { workspace: personal, source: "personal" };
262
+ // Last resort only — see this module's header for why it is last and why
263
+ // it is still here.
264
+ return { workspace: workspaces[0] ?? null, source: "positional" };
265
+ }, [loading, workspaces, memberOf, chosenId, urlWorkspaceId, localId, query.data]);
266
+
267
+ // The URL named something the person cannot open. Deliberately NOT an
268
+ // error state that blanks the screen, and deliberately NOT a silent
269
+ // substitution: they clicked a link expecting workspace X, and quietly
270
+ // showing workspace Y's data is the same misattribution class as #239.
271
+ // They land through the rest of the chain, and this flag drives the notice.
272
+ const urlWorkspaceInvalid =
273
+ !loading &&
274
+ workspaces.length > 0 &&
275
+ chosenId === null &&
276
+ !!urlWorkspaceId &&
277
+ memberOf(urlWorkspaceId) === null;
278
+
279
+ // Correct the address bar once, with a REPLACE so the broken URL does not
280
+ // stay reachable with the back button.
281
+ const correctedFor = useRef<string | null>(null);
282
+ useEffect(() => {
283
+ if (!urlWorkspaceInvalid || !resolved.workspace) return;
284
+ if (correctedFor.current === urlWorkspaceId) return;
285
+ correctedFor.current = urlWorkspaceId ?? null;
286
+ onUrlWorkspaceChange?.(resolved.workspace.id, {
287
+ reason: "invalid-url",
288
+ history: "replace",
289
+ });
290
+ }, [urlWorkspaceInvalid, resolved.workspace, urlWorkspaceId, onUrlWorkspaceChange]);
291
+
292
+ // A stale local pointer is DELETED rather than rewritten to the fallback.
293
+ // Writing the fallback would ossify a guess into something that outranks a
294
+ // later-corrected backend preference or instance default; deleting it just
295
+ // stops the next boot retrying a dead id.
296
+ useEffect(() => {
297
+ if (loading || !repo || localId === null) return;
298
+ if (memberOf(localId)) return;
299
+ setLocalId(null);
300
+ void repo.del(REPO_KEY).catch(() => undefined);
301
+ }, [loading, repo, localId, memberOf]);
302
+
303
+ /**
304
+ * The only path that persists anything, and that is the whole write policy:
305
+ * a URL is per-tab CONTEXT, a picker click is a CHOICE.
306
+ *
307
+ * If resolving from a shared link wrote the preference, one link pasted into
308
+ * a chat would permanently repoint its recipient's home — so a URL-driven
309
+ * resolution writes nothing at all, on either layer. A person who is in a
310
+ * URL-driven workspace and then picks it in the switcher is making a choice,
311
+ * and that goes through here.
312
+ */
313
+ const switchTo = useCallback(
314
+ (workspaceId: string) => {
315
+ if (!workspaces.some((w) => w.id === workspaceId)) return;
316
+ setChosenId(workspaceId);
317
+ if (repo) void repo.set(REPO_KEY, workspaceId).catch(() => undefined);
318
+ // Fire-and-forget: the tab has already switched. A switch that blocks on
319
+ // a flaky network is worse than a preference that lags one click.
320
+ setPreferred.mutate({ workspace_id: workspaceId });
321
+ onUrlWorkspaceChange?.(workspaceId, {
322
+ reason: "switch",
323
+ history: "push",
324
+ });
325
+ },
326
+ [workspaces, repo, setPreferred, onUrlWorkspaceChange]
327
+ );
328
+
329
+ const refetch = useCallback(async () => {
330
+ await query.refetch();
331
+ }, [query]);
332
+
333
+ // Memoised, and that is load-bearing rather than a performance nicety
334
+ // (ironmemo #251): consumers put `current` straight into `useEffect`
335
+ // dependency arrays to refetch when the workspace changes. A fresh object
336
+ // literal here would be a new value on every render of this provider, so
337
+ // every such effect would re-run on every render — and when the effect also
338
+ // sets state, that is an unbounded render loop whose only symptom is a
339
+ // spinner that never resolves, with nothing in the console.
340
+ const value = useMemo<WorkspaceSelection>(
341
+ () => ({
342
+ workspaces,
343
+ current: resolved.workspace,
344
+ source: resolved.source,
345
+ urlWorkspaceInvalid,
346
+ loading,
347
+ switchTo,
348
+ refetch,
349
+ }),
350
+ [
351
+ workspaces,
352
+ resolved.workspace,
353
+ resolved.source,
354
+ urlWorkspaceInvalid,
355
+ loading,
356
+ switchTo,
357
+ refetch,
358
+ ]
359
+ );
360
+
361
+ return (
362
+ <SelectionContext.Provider value={value}>
363
+ {children}
364
+ </SelectionContext.Provider>
365
+ );
366
+ }
367
+
368
+ /**
369
+ * The resolved active workspace. Throws outside
370
+ * {@link WorkspaceSelectionProvider} — a silent `null` here would be read as
371
+ * "no workspace yet" and produce exactly the blank screen this pair exists to
372
+ * prevent.
373
+ */
374
+ export function useWorkspaceSelection(): WorkspaceSelection {
375
+ const value = useContext(SelectionContext);
376
+ if (!value) {
377
+ throw new Error(
378
+ "useWorkspaceSelection must be used inside <WorkspaceSelectionProvider>"
379
+ );
380
+ }
381
+ return value;
382
+ }