@stapel/workspaces-react 0.9.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/api/generated/schema.d.ts +283 -17
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +23 -0
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/workspacesApi.d.ts +37 -1
  7. package/dist/api/workspacesApi.d.ts.map +1 -1
  8. package/dist/api/workspacesApi.js +4 -0
  9. package/dist/api/workspacesApi.js.map +1 -1
  10. package/dist/i18n/generated/errors.gen.d.ts +30 -0
  11. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  12. package/dist/i18n/generated/errors.gen.js +15 -0
  13. package/dist/i18n/generated/errors.gen.js.map +1 -1
  14. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  15. package/dist/i18n/generated/errors.ru.gen.js +5 -0
  16. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/model/mutations.d.ts +98 -1
  22. package/dist/model/mutations.d.ts.map +1 -1
  23. package/dist/model/mutations.js +132 -0
  24. package/dist/model/mutations.js.map +1 -1
  25. package/dist/model/queryKeys.d.ts +1 -0
  26. package/dist/model/queryKeys.d.ts.map +1 -1
  27. package/dist/model/queryKeys.js +7 -0
  28. package/dist/model/queryKeys.js.map +1 -1
  29. package/dist/model/selection.d.ts +146 -0
  30. package/dist/model/selection.d.ts.map +1 -0
  31. package/dist/model/selection.js +217 -0
  32. package/dist/model/selection.js.map +1 -0
  33. package/llms.txt +11 -3
  34. package/manifest.json +117 -2
  35. package/package.json +2 -2
  36. package/src/analytics/generated/events.json +1 -1
  37. package/src/api/generated/schema.ts +283 -17
  38. package/src/api/types.ts +23 -0
  39. package/src/api/workspacesApi.ts +74 -0
  40. package/src/i18n/generated/errors.gen.ts +15 -0
  41. package/src/i18n/generated/errors.json +35 -0
  42. package/src/i18n/generated/errors.ru.gen.ts +5 -0
  43. package/src/index.ts +22 -0
  44. package/src/model/mutations.ts +186 -0
  45. package/src/model/queryKeys.ts +8 -0
  46. package/src/model/selection.tsx +382 -0
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,
@@ -89,6 +101,8 @@ export {
89
101
  useInviteMembers,
90
102
  useUpdateMemberRole,
91
103
  useRemoveMember,
104
+ useRenameMember,
105
+ useRenameInvitation,
92
106
  useAcceptInvitation,
93
107
  useClaimInvitation,
94
108
  useDeclineInvitation,
@@ -96,9 +110,13 @@ export {
96
110
  useResendInvitation,
97
111
  useResetMemberPassword,
98
112
  useUpdateSecuritySettings,
113
+ useSetPreferredWorkspace,
114
+ useClearPreferredWorkspace,
99
115
  } from "./model/mutations.js";
100
116
  export type {
101
117
  MemberRoleChange,
118
+ MemberRename,
119
+ InvitationRename,
102
120
  MemberPasswordResetVars,
103
121
  } from "./model/mutations.js";
104
122
 
@@ -123,6 +141,10 @@ export type {
123
141
  InvitationClaim,
124
142
  MemberPasswordReset,
125
143
  MemberPasswordResetResult,
144
+ PreferredWorkspace,
145
+ PreferredWorkspaceResult,
146
+ DisplayNameUpdate,
147
+ DisplayNameResult,
126
148
  ProvisionedUserPolicy,
127
149
  WorkspaceSecuritySettings,
128
150
  RoleInfo,
@@ -5,6 +5,7 @@ import type {
5
5
  } from "@tanstack/react-query";
6
6
  import type { StapelApiError } from "@stapel/core";
7
7
  import type {
8
+ DisplayNameResult,
8
9
  Invitation,
9
10
  Member,
10
11
  MemberInvite,
@@ -13,6 +14,8 @@ import type {
13
14
  MemberPasswordResetResult,
14
15
  InvitationAccept,
15
16
  InvitationClaim,
17
+ PreferredWorkspace,
18
+ PreferredWorkspaceResult,
16
19
  Workspace,
17
20
  WorkspaceCreate,
18
21
  WorkspaceSecuritySettings,
@@ -179,6 +182,132 @@ export function useRemoveMember(
179
182
  return useMutation(options);
180
183
  }
181
184
 
185
+ /** The variable for {@link useRenameMember}: whose name, and the new one.
186
+ * `displayName` is nullable because clearing IS a supported outcome — blank,
187
+ * whitespace-only and `null` all mean "no name for this person" to the
188
+ * backend, which then answers with an empty string. */
189
+ export interface MemberRename {
190
+ readonly userId: string;
191
+ readonly displayName: string | null;
192
+ }
193
+
194
+ /**
195
+ * Correct a co-member's display name (PATCH /{id}/members/{userId}/name,
196
+ * stapel-workspaces ≥0.19.0) — the roster-side fix an owner/admin applies
197
+ * without waiting for the person themselves.
198
+ *
199
+ * **Capability `members.role.change`**, not `members.invite`: ask
200
+ * {@link "./queries.js".useCapabilityGate}(workspaceId,
201
+ * "members.role.change") before offering the affordance. It is `standard`, so
202
+ * no step-up is demanded (contrast {@link useResetMemberPassword}). Anonymous
203
+ * callers are denied outright, and only an owner may rename an owner.
204
+ *
205
+ * **What the write moves, and hence what is invalidated.** The backend writes
206
+ * the CANONICAL name — stapel-profiles' `Profile.display_name` through the
207
+ * in-process profiles seam — not the membership's local
208
+ * `display_name_hint`, which goes dark the moment a real profile exists. So
209
+ * the stale set is not "the page this row was on": it is EVERY cached roster,
210
+ * in every workspace, because `MemberResponse.display_name` is a live lookup
211
+ * of that one canonical value. Hence
212
+ * {@link "./queryKeys.js".workspacesQueryKeys.membersAll}, the workspace-less
213
+ * prefix — invalidating `members(workspaceId)` would leave the same person
214
+ * showing their old name on every other roster the session has open. A
215
+ * rename can also move a row out of an active `search` filter, which the
216
+ * prefix covers too (every page, every filter).
217
+ *
218
+ * A host that ALSO renders `@stapel/profiles-react` data for this person owns
219
+ * the other half: this pair never reaches into another pair's query namespace
220
+ * (the same seam discipline as `InviteAcceptFlow`'s basic-data slot), so
221
+ * invalidate `profilesQueryKeys.profile(userId)` yourself there.
222
+ *
223
+ * **Errors** arrive in the single dialect, keyed: the four display-name rules
224
+ * borrowed verbatim from stapel-profiles
225
+ * (`error.400.display_name_too_short` / `_forbidden_chars` /
226
+ * `_invisible_chars` / `_emoji`), over-length as the fleet-standard
227
+ * `error.400.field.max_length` with `{field, max_length}` (no bespoke code),
228
+ * and `error.503.profiles_unavailable` where stapel-profiles does not run in
229
+ * the deployment's process — an honest refusal rather than a 200 over a write
230
+ * that did not happen. Render them with `explainWorkspacesError` / `t(code,
231
+ * params)` like every other key.
232
+ */
233
+ export function useRenameMember(
234
+ workspaceId: string
235
+ ): UseMutationResult<DisplayNameResult, StapelApiError, MemberRename> {
236
+ const api = useWorkspacesApi();
237
+ const queryClient = useQueryClient();
238
+ const options: UseMutationOptions<
239
+ DisplayNameResult,
240
+ StapelApiError,
241
+ MemberRename
242
+ > = {
243
+ mutationFn: ({ userId, displayName }) =>
244
+ api.renameMember(workspaceId, userId, { display_name: displayName }),
245
+ onSuccess: () => {
246
+ void queryClient.invalidateQueries({
247
+ queryKey: workspacesQueryKeys.membersAll(),
248
+ });
249
+ },
250
+ };
251
+ return useMutation(options);
252
+ }
253
+
254
+ /** The variable for {@link useRenameInvitation}: which pending invitation, and
255
+ * the corrected name hint (nullable for the same reason as
256
+ * {@link MemberRename}). */
257
+ export interface InvitationRename {
258
+ readonly invitationId: string;
259
+ readonly displayName: string | null;
260
+ }
261
+
262
+ /**
263
+ * Fix a pending invitation's name hint (PATCH
264
+ * /{id}/invitations/{invitationId}/name, stapel-workspaces ≥0.19.0) — the
265
+ * same correction as {@link useRenameMember}, one step earlier. The invitee
266
+ * has not accepted, so there is no profile of theirs to write and the name
267
+ * lives on the invitation; `accept_invitation` copies it onto the membership.
268
+ * Before this endpoint the only fix for a typo in an invitee's name was
269
+ * revoke-and-re-invite, which re-mails the person.
270
+ *
271
+ * **The same capability as the member rename** (`members.role.change`, not
272
+ * the invitation surface's `members.invite`) — the hint IS the member's name
273
+ * after acceptance, so splitting them would let a role fix a name that
274
+ * reverts.
275
+ *
276
+ * Only a PENDING invitation is editable; a terminal one refuses with the
277
+ * keyed answers revoke gives (`error.400.invitation_already_used` /
278
+ * `_revoked` / `_declined` / `_expired`), and an accepted invitation's name
279
+ * is the member's name now — use {@link useRenameMember}. Validation is the
280
+ * same stapel-profiles canon, so the same four display-name keys surface
281
+ * here.
282
+ *
283
+ * Invalidation is deliberately NARROWER than the member rename's: the hint is
284
+ * a workspace-local column on one invitation, not a shared canonical name, so
285
+ * only this workspace's invitation lists (every page and the infinite walk —
286
+ * the bare 3-tuple prefix) can be showing it.
287
+ */
288
+ export function useRenameInvitation(
289
+ workspaceId: string
290
+ ): UseMutationResult<DisplayNameResult, StapelApiError, InvitationRename> {
291
+ const api = useWorkspacesApi();
292
+ const queryClient = useQueryClient();
293
+ const options: UseMutationOptions<
294
+ DisplayNameResult,
295
+ StapelApiError,
296
+ InvitationRename
297
+ > = {
298
+ mutationFn: ({ invitationId, displayName }) =>
299
+ api.renameInvitation(workspaceId, invitationId, {
300
+ display_name: displayName,
301
+ }),
302
+ onSuccess: () => {
303
+ void queryClient.invalidateQueries({
304
+ queryKey: workspacesQueryKeys.invitations(workspaceId),
305
+ });
306
+ },
307
+ };
308
+ return useMutation(options);
309
+ }
310
+
182
311
  /**
183
312
  * Withdraw a live invitation (POST /{id}/invitations/{invitationId}/revoke,
184
313
  * #109). The variable is the invitation id; the reply is the UPDATED row, so
@@ -438,3 +567,60 @@ export function useDeclineInvitation(): UseMutationResult<
438
567
  };
439
568
  return useMutation(options);
440
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
+ }
@@ -14,6 +14,7 @@ export const workspacesQueryKeys: {
14
14
  list(): readonly ["workspaces", "list"];
15
15
  detail(workspaceId: string): readonly ["workspaces", "detail", string];
16
16
  members(workspaceId: string): readonly ["workspaces", "members", string];
17
+ membersAll(): readonly ["workspaces", "members"];
17
18
  membersPage(
18
19
  workspaceId: string,
19
20
  params: MembersParams
@@ -45,6 +46,13 @@ export const workspacesQueryKeys: {
45
46
  // The bare 3-tuple is a valid prefix of membersPage's 4-tuple, so
46
47
  // invalidating `members(workspaceId)` (mutations.ts) drops every page.
47
48
  members: (workspaceId) => [ROOT, "members", workspaceId],
49
+ // EVERY workspace's roster, not one. Not a read key — nothing fetches it —
50
+ // but the invalidation scope of a write that moves a name the whole fleet
51
+ // shares: `MemberResponse.display_name` is a live lookup in
52
+ // stapel-profiles, so a rename lands on the person's CANONICAL name and
53
+ // every roster that lists them is stale at once, not just the one the
54
+ // admin happened to be looking at.
55
+ membersAll: () => [ROOT, "members"],
48
56
  membersPage: (workspaceId, params) => [ROOT, "members", workspaceId, params],
49
57
  // Same prefix trick as `members`: the bare 3-tuple invalidates every page
50
58
  // (and the infinite list, which keys on it) after a revoke / resend.
@@ -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
+ }